Merge branch 'develop'
This commit is contained in:
commit
f68343f03e
155
app/public/openapi.yml
Normal file
155
app/public/openapi.yml
Normal file
@ -0,0 +1,155 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Colouring London API
|
||||
version: 1.0.0
|
||||
|
||||
servers:
|
||||
- url: https://colouring.london/api
|
||||
description: Production server (uses live data)
|
||||
|
||||
paths:
|
||||
|
||||
/extracts:
|
||||
get:
|
||||
summary: Returns a list of bulk data extracts
|
||||
responses:
|
||||
'200':
|
||||
description: A list of bulk extracts, from newest to oldest
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
extracts:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BulkExtract'
|
||||
example:
|
||||
extracts:
|
||||
- extract_id: 1
|
||||
extracted_on: 2019-10-03T05:33:00.000Z
|
||||
download_path: /downloads/data-extract-2019-10-03-06_33_00.zip
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'500':
|
||||
$ref: '#/components/responses/ServerError'
|
||||
|
||||
/history:
|
||||
get:
|
||||
summary: Returns a paginated list of edits (latest edits if no relevant parameters specified)
|
||||
parameters:
|
||||
- name: before_id
|
||||
description: Returned edits will be ones made directly before the specified revision ID
|
||||
in: query
|
||||
schema:
|
||||
$ref: '#/components/schemas/RevisionId'
|
||||
required: false
|
||||
- name: after_id
|
||||
description: Returned edits will be ones made directly after the specified revision ID
|
||||
in: query
|
||||
schema:
|
||||
$ref: '#/components/schemas/RevisionId'
|
||||
required: false
|
||||
- name: count
|
||||
description: The desired number of records to return
|
||||
in: query
|
||||
schema:
|
||||
type: number
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 100
|
||||
required: false
|
||||
responses:
|
||||
'200':
|
||||
description: A list of edit history records
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
history:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BuildingEditHistoryEntry'
|
||||
paging:
|
||||
type: object
|
||||
properties:
|
||||
id_for_older_query:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RevisionId'
|
||||
- description: If older records exist - ID to use for querying them (use as before_id param), otherwise null
|
||||
nullable: true
|
||||
id_for_newer_query:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RevisionId'
|
||||
- description: If newer records exist - ID to use for querying them (use as after_id param), otherwise null
|
||||
nullable: true
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'500':
|
||||
$ref: '#/components/responses/ServerError'
|
||||
|
||||
components:
|
||||
responses:
|
||||
BadRequest:
|
||||
description: Invalid request submitted by user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
|
||||
ServerError:
|
||||
description: Unexpected server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
example:
|
||||
error: Database error
|
||||
|
||||
schemas:
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
description: Error message
|
||||
|
||||
BulkExtract:
|
||||
type: object
|
||||
properties:
|
||||
extract_id:
|
||||
type: integer
|
||||
description: Unique sequential ID for the extract
|
||||
extracted_on:
|
||||
type: string
|
||||
format: date-time
|
||||
description: UTC timestamp at which the extract was generated
|
||||
download_path:
|
||||
type: string
|
||||
description: Download path for the extract. Contains only URL path (should be used with the same hostname as the API).
|
||||
|
||||
RevisionId:
|
||||
description: Unique sequential ID for an edit history entry (positive big integer)
|
||||
type: string
|
||||
pattern: ^[1-9]\d*&
|
||||
|
||||
BuildingEditHistoryEntry:
|
||||
type: object
|
||||
properties:
|
||||
revision_id:
|
||||
$ref: '#/components/schemas/RevisionId'
|
||||
forward_patch:
|
||||
type: object
|
||||
description: Forward diff of the building attribute data
|
||||
reverse_patch:
|
||||
type: object
|
||||
description: Reverse diff of the building attribute data
|
||||
revision_timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
description: UTC timestamp at which the building data was edited
|
||||
username:
|
||||
type: string
|
||||
description: Username of the editor
|
||||
building_id:
|
||||
type: number
|
||||
description: Unique ID of the edited building
|
@ -2,6 +2,8 @@ import bodyParser from 'body-parser';
|
||||
import express from 'express';
|
||||
|
||||
import * as editHistoryController from './controllers/editHistoryController';
|
||||
import { ApiParamError, ApiUserError } from './errors/api';
|
||||
import { DatabaseError } from './errors/general';
|
||||
import buildingsRouter from './routes/buildingsRouter';
|
||||
import extractsRouter from './routes/extractsRouter';
|
||||
import usersRouter from './routes/usersRouter';
|
||||
@ -93,14 +95,30 @@ server.get('/search', function (req, res) {
|
||||
});
|
||||
});
|
||||
|
||||
server.use((err, req, res, next) => {
|
||||
server.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
if (res.headersSent) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
if (err != undefined) {
|
||||
console.log('Global error handler: ', err);
|
||||
res.status(500).send({ error: 'Server error' });
|
||||
|
||||
if (err instanceof ApiUserError) {
|
||||
let errorMessage: string;
|
||||
|
||||
if(err instanceof ApiParamError) {
|
||||
errorMessage = `Problem with parameter ${err.paramName}: ${err.message}`;
|
||||
} else {
|
||||
errorMessage = err.message;
|
||||
}
|
||||
|
||||
res.status(400).send({ error: errorMessage });
|
||||
} else if(err instanceof DatabaseError){
|
||||
res.status(500).send({ error: 'Database error' });
|
||||
} else {
|
||||
res.status(500).send({ error: 'Server error' });
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import express from 'express';
|
||||
|
||||
import { parseIntParam } from '../helpers';
|
||||
import { parsePositiveIntParam, processParam } from '../parameters';
|
||||
import asyncController from '../routes/asyncController';
|
||||
import * as buildingService from '../services/building';
|
||||
import * as userService from '../services/user';
|
||||
@ -35,7 +35,7 @@ const getBuildingsByReference = asyncController(async (req: express.Request, res
|
||||
|
||||
// GET individual building, POST building updates
|
||||
const getBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
const buildingId = parseIntParam(req.params.building_id);
|
||||
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
|
||||
|
||||
try {
|
||||
const result = await buildingService.getBuildingById(buildingId);
|
||||
@ -63,7 +63,7 @@ const updateBuildingById = asyncController(async (req: express.Request, res: exp
|
||||
});
|
||||
|
||||
async function updateBuilding(req: express.Request, res: express.Response, userId: string) {
|
||||
const buildingId = parseIntParam(req.params.building_id);
|
||||
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
|
||||
|
||||
const buildingUpdate = req.body;
|
||||
|
||||
@ -84,7 +84,7 @@ async function updateBuilding(req: express.Request, res: express.Response, userI
|
||||
|
||||
// GET building UPRNs
|
||||
const getBuildingUPRNsById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
const buildingId = parseIntParam(req.params.building_id);
|
||||
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
|
||||
|
||||
try {
|
||||
const result = await buildingService.getBuildingUPRNsById(buildingId);
|
||||
@ -105,7 +105,7 @@ const getBuildingLikeById = asyncController(async (req: express.Request, res: ex
|
||||
return res.send({ like: false }); // not logged in, so cannot have liked
|
||||
}
|
||||
|
||||
const buildingId = parseIntParam(req.params.building_id);
|
||||
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
|
||||
|
||||
try {
|
||||
const like = await buildingService.getBuildingLikeById(buildingId, req.session.user_id);
|
||||
@ -118,7 +118,7 @@ const getBuildingLikeById = asyncController(async (req: express.Request, res: ex
|
||||
});
|
||||
|
||||
const getBuildingEditHistoryById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
const buildingId = parseIntParam(req.params.building_id);
|
||||
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
|
||||
|
||||
try {
|
||||
const editHistory = await buildingService.getBuildingEditHistory(buildingId);
|
||||
@ -134,7 +134,7 @@ const updateBuildingLikeById = asyncController(async (req: express.Request, res:
|
||||
return res.send({ error: 'Must be logged in' });
|
||||
}
|
||||
|
||||
const buildingId = parseIntParam(req.params.building_id);
|
||||
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
|
||||
const { like } = req.body;
|
||||
|
||||
try {
|
||||
|
@ -1,17 +1,32 @@
|
||||
import express from 'express';
|
||||
|
||||
import { ApiParamError, ApiUserError } from '../errors/api';
|
||||
import { ArgumentError } from '../errors/general';
|
||||
import { checkRegexParam, parsePositiveIntParam, processParam } from '../parameters';
|
||||
import asyncController from "../routes/asyncController";
|
||||
import * as editHistoryService from '../services/editHistory';
|
||||
|
||||
const getGlobalEditHistory = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
|
||||
const revisionIdRegex = /^[1-9]\d*$/;
|
||||
const afterId: string = processParam(req.query, 'after_id', x => checkRegexParam(x, revisionIdRegex));
|
||||
const beforeId: string = processParam(req.query, 'before_id', x => checkRegexParam(x, revisionIdRegex));
|
||||
const count: number = processParam(req.query, 'count', parsePositiveIntParam);
|
||||
|
||||
if(afterId != undefined && beforeId != undefined) {
|
||||
throw new ApiUserError('Cannot specify both after_id and before_id parameters');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await editHistoryService.getGlobalEditHistory();
|
||||
res.send({
|
||||
history: result
|
||||
});
|
||||
const result = await editHistoryService.getGlobalEditHistory(beforeId, afterId, count);
|
||||
res.send(result);
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
res.send({ error: 'Database error' });
|
||||
if(error instanceof ArgumentError && error.argumentName === 'count') {
|
||||
const apiErr = new ApiParamError(error.message, 'count');
|
||||
throw apiErr;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import express from 'express';
|
||||
|
||||
import { parseIntParam } from '../helpers';
|
||||
import { parsePositiveIntParam, processParam } from '../parameters';
|
||||
import asyncController from '../routes/asyncController';
|
||||
import * as dataExtractService from '../services/dataExtract';
|
||||
|
||||
@ -15,8 +15,9 @@ const getAllDataExtracts = asyncController(async function(req: express.Request,
|
||||
});
|
||||
|
||||
const getDataExtract = asyncController(async function(req: express.Request, res: express.Response) {
|
||||
const extractId = processParam(req.params, 'extract_id', parsePositiveIntParam, true);
|
||||
|
||||
try {
|
||||
const extractId = parseIntParam(req.params.extract_id);
|
||||
const extract = await dataExtractService.getDataExtractById(extractId);
|
||||
res.send({ extract: extract });
|
||||
} catch (err) {
|
||||
|
62
app/src/api/dataAccess/__mocks__/editHistory.ts
Normal file
62
app/src/api/dataAccess/__mocks__/editHistory.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { EditHistoryEntry } from '../../../frontend/models/edit-history-entry';
|
||||
import { numAsc, numDesc } from '../../../helpers';
|
||||
|
||||
/**
|
||||
* Create an object mocking all method of editHistory dataAccess
|
||||
* The type is set to reflect the type of that module, with added methods
|
||||
* used when testing
|
||||
*/
|
||||
const mockEditHistory =
|
||||
jest.genMockFromModule('../editHistory') as typeof import('../editHistory') & {
|
||||
__setHistory: (mockHistoryData: EditHistoryEntry[]) => void
|
||||
};
|
||||
|
||||
let mockData: EditHistoryEntry[] = [];
|
||||
|
||||
mockEditHistory.__setHistory = function(mockHistoryData: EditHistoryEntry[]) {
|
||||
mockData = mockHistoryData.sort(numDesc(x => BigInt(x.revision_id)));
|
||||
};
|
||||
|
||||
mockEditHistory.getHistoryAfterId = function(id: string, count: number): Promise<EditHistoryEntry[]> {
|
||||
return Promise.resolve(
|
||||
mockData
|
||||
.filter(x => BigInt(x.revision_id) > BigInt(id))
|
||||
.sort(numAsc(x => BigInt(x.revision_id)))
|
||||
.slice(0, count)
|
||||
.sort(numDesc(x => BigInt(x.revision_id)))
|
||||
);
|
||||
};
|
||||
|
||||
mockEditHistory.getHistoryBeforeId = function(id: string, count: number): Promise<EditHistoryEntry[]> {
|
||||
let filteredData = id == undefined ? mockData : mockData.filter(x => BigInt(x.revision_id) < BigInt(id));
|
||||
return Promise.resolve(
|
||||
filteredData
|
||||
.slice(0, count)
|
||||
);
|
||||
};
|
||||
|
||||
mockEditHistory.getIdNewerThan = async function(id: string): Promise<string> {
|
||||
const historyAfterId = await mockEditHistory.getHistoryAfterId(id, 1);
|
||||
return historyAfterId[historyAfterId.length - 1]?.revision_id;
|
||||
};
|
||||
|
||||
mockEditHistory.getIdOlderThan = async function(id: string): Promise<string> {
|
||||
const historyBeforeId = await mockEditHistory.getHistoryBeforeId(id, 1);
|
||||
return historyBeforeId[0]?.revision_id;
|
||||
};
|
||||
|
||||
const {
|
||||
__setHistory,
|
||||
getHistoryAfterId,
|
||||
getHistoryBeforeId,
|
||||
getIdNewerThan,
|
||||
getIdOlderThan
|
||||
} = mockEditHistory;
|
||||
|
||||
export {
|
||||
__setHistory,
|
||||
getHistoryAfterId,
|
||||
getHistoryBeforeId,
|
||||
getIdNewerThan,
|
||||
getIdOlderThan
|
||||
};
|
88
app/src/api/dataAccess/editHistory.ts
Normal file
88
app/src/api/dataAccess/editHistory.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import db from '../../db';
|
||||
import { EditHistoryEntry } from '../../frontend/models/edit-history-entry';
|
||||
import { DatabaseError } from '../errors/general';
|
||||
|
||||
const baseQuery = `
|
||||
SELECT
|
||||
log_id as revision_id,
|
||||
forward_patch,
|
||||
reverse_patch,
|
||||
date_trunc('minute', log_timestamp) as revision_timestamp,
|
||||
username,
|
||||
building_id
|
||||
FROM logs
|
||||
JOIN users ON logs.user_id = users.user_id`;
|
||||
|
||||
export function getHistoryAfterId(id: string, count: number): Promise<EditHistoryEntry[]> {
|
||||
/**
|
||||
* SQL with lower time bound specified (records after ID).
|
||||
* The outer SELECT is so that final results are sorted by descending ID
|
||||
* (like the other queries). The inner select is sorted in ascending order
|
||||
* so that the right rows are returned when limiting the result set.
|
||||
*/
|
||||
try {
|
||||
return db.any(`
|
||||
SELECT * FROM (
|
||||
${baseQuery}
|
||||
WHERE log_id > $1
|
||||
ORDER BY revision_id ASC
|
||||
LIMIT $2
|
||||
) AS result_asc ORDER BY revision_id DESC`,
|
||||
[id, count]
|
||||
);
|
||||
} catch(err) {
|
||||
throw new DatabaseError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export function getHistoryBeforeId(id: string, count: number): Promise<EditHistoryEntry[]> {
|
||||
try {
|
||||
if(id == undefined) {
|
||||
|
||||
return db.any(`
|
||||
${baseQuery}
|
||||
ORDER BY revision_id DESC
|
||||
LIMIT $1
|
||||
`, [count]);
|
||||
|
||||
} else {
|
||||
|
||||
return db.any(`
|
||||
${baseQuery}
|
||||
WHERE log_id < $1
|
||||
ORDER BY revision_id DESC
|
||||
LIMIT $2
|
||||
`, [id, count]);
|
||||
}
|
||||
} catch(err) {
|
||||
throw new DatabaseError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getIdOlderThan(id: string): Promise<string> {
|
||||
try {
|
||||
const result = await db.oneOrNone<{revision_id:string}>(`
|
||||
SELECT MAX(log_id) as revision_id
|
||||
FROM logs
|
||||
WHERE log_id < $1
|
||||
`, [id]);
|
||||
|
||||
return result?.revision_id;
|
||||
} catch(err) {
|
||||
throw new DatabaseError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getIdNewerThan(id: string): Promise<string> {
|
||||
try {
|
||||
const result = await db.oneOrNone<{revision_id:string}>(`
|
||||
SELECT MIN(log_id) as revision_id
|
||||
FROM logs
|
||||
WHERE log_id > $1
|
||||
`, [id]);
|
||||
|
||||
return result?.revision_id;
|
||||
} catch(err) {
|
||||
throw new DatabaseError(err);
|
||||
}
|
||||
}
|
44
app/src/api/errors/api.ts
Normal file
44
app/src/api/errors/api.ts
Normal file
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Note that custom errors and the instanceof operator in TS work together
|
||||
* only when transpiling to ES2015 and up.
|
||||
* For earier target versions (ES5), a workaround is required:
|
||||
* https://stackoverflow.com/questions/41102060/typescript-extending-error-class
|
||||
*/
|
||||
|
||||
export class ApiUserError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
this.name = 'ApiUserError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiParamError extends ApiUserError {
|
||||
public paramName: string;
|
||||
|
||||
constructor(message?: string, paramName?: string) {
|
||||
super(message);
|
||||
this.name = 'ApiParamError';
|
||||
this.paramName = paramName;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiParamRequiredError extends ApiParamError {
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
this.name = 'ApiParamRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiParamOutOfBoundsError extends ApiParamError {
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
this.name = 'ApiParamOutOfBoundsError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiParamInvalidFormatError extends ApiParamError {
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
this.name = 'ApiParamInvalidFormatError';
|
||||
}
|
||||
}
|
17
app/src/api/errors/general.ts
Normal file
17
app/src/api/errors/general.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export class ArgumentError extends Error {
|
||||
public argumentName: string;
|
||||
constructor(message?: string, argumentName?: string) {
|
||||
super(message);
|
||||
this.name = 'ArgumentError';
|
||||
this.argumentName = argumentName;
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseError extends Error {
|
||||
public detail: any;
|
||||
constructor(detail?: string) {
|
||||
super();
|
||||
this.name = 'DatabaseError';
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import { strictParseInt } from '../parse';
|
||||
|
||||
export function parseIntParam(param: string) {
|
||||
const result = strictParseInt(param);
|
||||
if (isNaN(result)) {
|
||||
throw new Error('Invalid parameter format: not an integer');
|
||||
}
|
||||
return result;
|
||||
}
|
44
app/src/api/parameters.ts
Normal file
44
app/src/api/parameters.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { strictParseInt } from '../parse';
|
||||
|
||||
import { ApiParamError, ApiParamInvalidFormatError, ApiParamRequiredError } from './errors/api';
|
||||
|
||||
|
||||
export function processParam<T>(params: object, paramName: string, processingFn: (x: string) => T, required: boolean = false) {
|
||||
const stringValue = params[paramName];
|
||||
|
||||
if(stringValue == undefined && required) {
|
||||
const err = new ApiParamRequiredError('Parameter required but not supplied');
|
||||
err.paramName = paramName;
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
return processingFn(stringValue);
|
||||
} catch(error) {
|
||||
if(error instanceof ApiParamError) {
|
||||
error.paramName = paramName;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePositiveIntParam(param: string) {
|
||||
if(param == undefined) return undefined;
|
||||
|
||||
const result = strictParseInt(param);
|
||||
if (isNaN(result)) {
|
||||
throw new ApiParamInvalidFormatError('Invalid format: not a positive integer');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function checkRegexParam(param: string, regex: RegExp): string {
|
||||
if(param == undefined) return undefined;
|
||||
|
||||
if(param.match(regex) == undefined) {
|
||||
throw new ApiParamInvalidFormatError(`Invalid format: does not match regular expression ${regex}`);
|
||||
}
|
||||
|
||||
return param;
|
||||
}
|
176
app/src/api/services/__tests__/editHistory.test.ts
Normal file
176
app/src/api/services/__tests__/editHistory.test.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import { EditHistoryEntry } from '../../../frontend/models/edit-history-entry';
|
||||
import * as editHistoryData from '../../dataAccess/editHistory'; // manually mocked
|
||||
import { ArgumentError } from '../../errors/general';
|
||||
import { getGlobalEditHistory } from '../editHistory';
|
||||
|
||||
jest.mock('../../dataAccess/editHistory');
|
||||
|
||||
const mockedEditHistoryData = editHistoryData as typeof import('../../dataAccess/__mocks__/editHistory');
|
||||
|
||||
function generateHistory(n: number, firstId: number = 100) {
|
||||
return [...Array(n).keys()].map<EditHistoryEntry>(i => ({
|
||||
revision_id: (firstId + i) + '',
|
||||
revision_timestamp: new Date(2019, 10, 1, 17, 20 + i).toISOString(),
|
||||
username: 'testuser',
|
||||
building_id: 1234567,
|
||||
forward_patch: {},
|
||||
reverse_patch: {}
|
||||
}));
|
||||
}
|
||||
|
||||
describe('getGlobalEditHistory()', () => {
|
||||
|
||||
beforeEach(() => mockedEditHistoryData.__setHistory(generateHistory(20)));
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it.each([
|
||||
[null, null],
|
||||
['100', null],
|
||||
[null, '100']
|
||||
])('Should error when requesting non-positive number of records', async (beforeId: string, afterId: string) => {
|
||||
let resultPromise = getGlobalEditHistory(beforeId, afterId, 0);
|
||||
await expect(resultPromise).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(resultPromise).rejects.toHaveProperty('argumentName', 'count');
|
||||
});
|
||||
|
||||
describe('getting history before a point', () => {
|
||||
|
||||
it('should return latest history if no ID specified', async () => {
|
||||
const result = await getGlobalEditHistory(null, null, 5);
|
||||
|
||||
expect(result.history.map(x => x.revision_id)).toEqual(['119', '118', '117', '116', '115']);
|
||||
});
|
||||
|
||||
it.each(
|
||||
[
|
||||
[null, 3, ['119', '118', '117']],
|
||||
[null, 6, ['119', '118', '117', '116', '115', '114']],
|
||||
['118', 1, ['117']],
|
||||
['104', 10, ['103', '102','101', '100']],
|
||||
['100', 2, []]
|
||||
]
|
||||
)('should return the N records before the specified ID in descending order [beforeId: %p, count: %p]', async (
|
||||
beforeId: string, count: number, ids: string[]
|
||||
) => {
|
||||
const result = await getGlobalEditHistory(beforeId, null, count);
|
||||
|
||||
expect(result.history.map(h => h.revision_id)).toEqual(ids);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, 4, null],
|
||||
[null, 10, null],
|
||||
[null, 20, null],
|
||||
[null, 30, null],
|
||||
['50', 10, '99'],
|
||||
['100', 10, '99'],
|
||||
['130', 10, null],
|
||||
['105', 2, '104'],
|
||||
['120', 20, null],
|
||||
])('should detect if there are any newer records left [beforeId: %p, count: %p]', async (
|
||||
beforeId: string, count: number, idForNewerQuery: string
|
||||
) => {
|
||||
const result = await getGlobalEditHistory(beforeId, null, count);
|
||||
|
||||
expect(result.paging.id_for_newer_query).toBe(idForNewerQuery);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, 4, '116'],
|
||||
[null, 10, '110'],
|
||||
[null, 20, null],
|
||||
[null, 30, null],
|
||||
['50', 10, null],
|
||||
['100', 10, null],
|
||||
['130', 10, '110'],
|
||||
['105', 2, '103'],
|
||||
['120', 20, null],
|
||||
])('should detect if there are any older records left [beforeId: %p, count: %p]', async (
|
||||
beforeId: string, count: number, idForOlderQuery: string
|
||||
) => {
|
||||
const result = await getGlobalEditHistory(beforeId, null, count);
|
||||
|
||||
expect(result.paging.id_for_older_query).toBe(idForOlderQuery);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('getting history after a point', () => {
|
||||
|
||||
it.each([
|
||||
['100', 7, ['107', '106', '105', '104', '103', '102', '101']],
|
||||
['115', 3, ['118', '117', '116']],
|
||||
['120', 10, []]
|
||||
])('should return N records after requested ID in descending order [afterId: %p, count: %p]', async (
|
||||
afterId: string, count: number, expected: string[]
|
||||
) => {
|
||||
const result = await getGlobalEditHistory(null, afterId, count);
|
||||
|
||||
expect(result.history.map(x => x.revision_id)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['99', 10, '109'],
|
||||
['110', 5, '115'],
|
||||
['119', 20, null],
|
||||
['99', 20, null],
|
||||
])('should detect if there are any newer records left [afterId: %p, count: %p]', async (
|
||||
afterId: string, count: number, idForNewerQuery: string
|
||||
) => {
|
||||
const result = await getGlobalEditHistory(null, afterId, count);
|
||||
|
||||
expect(result.paging.id_for_newer_query).toBe(idForNewerQuery);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['99', 10, null],
|
||||
['110', 5, '111'],
|
||||
['119', 20, '120'],
|
||||
['99', 20, null],
|
||||
])('should detect if there are any older records left [afterId: %p, count: %p]', async (
|
||||
afterId: string, count: number, idForOlderQuery: string
|
||||
) => {
|
||||
const result = await getGlobalEditHistory(null, afterId, count);
|
||||
|
||||
expect(result.paging.id_for_older_query).toBe(idForOlderQuery);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('result count limit', () => {
|
||||
|
||||
it.each([
|
||||
[null, null],
|
||||
[null, '100'],
|
||||
['300', null]
|
||||
])('should not return more than 100 entries (beforeId: %p, afterId: %p)', async (
|
||||
beforeId: string, afterId: string
|
||||
) => {
|
||||
mockedEditHistoryData.__setHistory(
|
||||
generateHistory(200)
|
||||
);
|
||||
|
||||
const result = await getGlobalEditHistory(beforeId, afterId, 200);
|
||||
|
||||
expect(result.history.length).toBe(100);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, null],
|
||||
[null, '100'],
|
||||
['300', null]
|
||||
])('should default to 100 entries', async (
|
||||
beforeId: string, afterId: string
|
||||
) => {
|
||||
mockedEditHistoryData.__setHistory(
|
||||
generateHistory(200)
|
||||
);
|
||||
|
||||
const result = await getGlobalEditHistory(beforeId, afterId);
|
||||
|
||||
expect(result.history.length).toBe(100);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
@ -118,7 +118,7 @@ async function getBuildingById(id: number) {
|
||||
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
|
||||
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp) as revision_timestamp, username
|
||||
FROM logs, users
|
||||
WHERE building_id = $1 AND logs.user_id = users.user_id
|
||||
ORDER BY log_timestamp DESC`,
|
||||
|
@ -1,20 +1,38 @@
|
||||
import db from '../../db';
|
||||
import { EditHistoryEntry } from '../../frontend/models/edit-history-entry';
|
||||
import { decBigInt, incBigInt } from '../../helpers';
|
||||
import { getHistoryAfterId, getHistoryBeforeId, getIdNewerThan, getIdOlderThan } from '../dataAccess/editHistory';
|
||||
import { ArgumentError } from '../errors/general';
|
||||
|
||||
async function getGlobalEditHistory() {
|
||||
try {
|
||||
return await db.manyOrNone(
|
||||
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp), username, building_id
|
||||
FROM logs, users
|
||||
WHERE logs.user_id = users.user_id
|
||||
AND log_timestamp >= now() - interval '7 days'
|
||||
ORDER BY log_timestamp DESC`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
async function getGlobalEditHistory(beforeId?: string, afterId?: string, count: number = 100) {
|
||||
if(count <= 0) throw new ArgumentError('cannot request less than 1 history record', 'count');
|
||||
if(count > 100) count = 100;
|
||||
|
||||
// limited set of records. Expected to be already ordered from newest to oldest
|
||||
let editHistoryRecords: EditHistoryEntry[];
|
||||
|
||||
if(afterId != undefined) {
|
||||
editHistoryRecords = await getHistoryAfterId(afterId, count);
|
||||
} else {
|
||||
editHistoryRecords = await getHistoryBeforeId(beforeId, count);
|
||||
}
|
||||
}
|
||||
|
||||
const currentBatchMaxId = editHistoryRecords[0]?.revision_id ?? decBigInt(beforeId);
|
||||
const newer = currentBatchMaxId && await getIdNewerThan(currentBatchMaxId);
|
||||
|
||||
const currentBatchMinId = editHistoryRecords[editHistoryRecords.length-1]?.revision_id ?? incBigInt(afterId);
|
||||
const older = currentBatchMinId && await getIdOlderThan(currentBatchMinId);
|
||||
|
||||
const idForOlderQuery = older != undefined ? incBigInt(older) : null;
|
||||
const idForNewerQuery = newer != undefined ? decBigInt(newer) : null;
|
||||
|
||||
return {
|
||||
history: editHistoryRecords,
|
||||
paging: {
|
||||
id_for_newer_query: idForNewerQuery,
|
||||
id_for_older_query: idForOlderQuery
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
getGlobalEditHistory
|
||||
|
@ -54,7 +54,7 @@ const BuildingEditSummary: React.FunctionComponent<BuildingEditSummaryProps> = (
|
||||
|
||||
return (
|
||||
<div className="edit-history-entry">
|
||||
<h2 className="edit-history-timestamp">Edited on {formatDate(parseDate(historyEntry.date_trunc))}</h2>
|
||||
<h2 className="edit-history-timestamp">Edited on {formatDate(parseDate(historyEntry.revision_timestamp))}</h2>
|
||||
<h3 className="edit-history-username">By {historyEntry.username}</h3>
|
||||
{
|
||||
showBuildingId && historyEntry.building_id != undefined &&
|
||||
|
@ -1,5 +1,5 @@
|
||||
export interface EditHistoryEntry {
|
||||
date_trunc: string;
|
||||
revision_timestamp: string;
|
||||
username: string;
|
||||
revision_id: string;
|
||||
forward_patch: object;
|
||||
|
7
app/src/frontend/pages/changes.css
Normal file
7
app/src/frontend/pages/changes.css
Normal file
@ -0,0 +1,7 @@
|
||||
.edit-history-link {
|
||||
margin-top: 4em;
|
||||
}
|
||||
|
||||
.edit-history-latest-link {
|
||||
float: right;
|
||||
}
|
@ -1,31 +1,88 @@
|
||||
import { parse } from 'query-string';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import './changes.css';
|
||||
|
||||
import { apiGet } from '../apiHelpers';
|
||||
import { BuildingEditSummary } from '../building/edit-history/building-edit-summary';
|
||||
import ErrorBox from '../components/error-box';
|
||||
import InfoBox from '../components/info-box';
|
||||
import { EditHistoryEntry } from '../models/edit-history-entry';
|
||||
|
||||
const ChangesPage = () => {
|
||||
const [history, setHistory] = useState<EditHistoryEntry[]>(undefined);
|
||||
interface PagingInfo {
|
||||
id_for_older_query: string;
|
||||
id_for_newer_query: string;
|
||||
}
|
||||
|
||||
const recordsPerPage = 20;
|
||||
|
||||
const ChangesPage = (props: RouteComponentProps) => {
|
||||
const { after_id, before_id } = parse(props.location.search);
|
||||
|
||||
const [history, setHistory] = useState<EditHistoryEntry[]>();
|
||||
const [paging, setPaging] = useState<PagingInfo>();
|
||||
const [error, setError] = useState();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const {history} = await apiGet(`/api/history`);
|
||||
setHistory(null);
|
||||
setPaging(null);
|
||||
|
||||
let url = `/api/history?count=${recordsPerPage}`;
|
||||
if(after_id) {
|
||||
url = `${url}&after_id=${after_id}`;
|
||||
}
|
||||
|
||||
if (before_id) {
|
||||
url = `${url}&before_id=${before_id}`;
|
||||
}
|
||||
try {
|
||||
const {history, paging, error} = await apiGet(url);
|
||||
|
||||
if(error) {
|
||||
setError(error);
|
||||
} else {
|
||||
setHistory(history);
|
||||
setPaging(paging);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Connection problem. Please try again later...');
|
||||
}
|
||||
|
||||
setHistory(history);
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
}, [props.location.search]);
|
||||
|
||||
return (
|
||||
<article>
|
||||
<section className="main-col">
|
||||
<h1>Global edit history</h1>
|
||||
|
||||
{
|
||||
paging?.id_for_newer_query &&
|
||||
<Link className='edit-history-link' to={`?after_id=${paging.id_for_newer_query}`}>Show more recent changes</Link>
|
||||
}
|
||||
{
|
||||
(after_id || before_id) &&
|
||||
<Link className='edit-history-latest-link' to='?'>Show latest changes</Link>
|
||||
}
|
||||
<ul className="edit-history-list">
|
||||
{(history == undefined || history.length == 0) ?
|
||||
<InfoBox msg="No changes in the last week"></InfoBox> :
|
||||
{
|
||||
error &&
|
||||
<ErrorBox msg={error}></ErrorBox>
|
||||
}
|
||||
{
|
||||
error == undefined && history == undefined &&
|
||||
<InfoBox msg="Loading history..."></InfoBox>
|
||||
}
|
||||
{
|
||||
(history?.length === 0) &&
|
||||
<InfoBox msg="No changes so far"></InfoBox>
|
||||
}
|
||||
{
|
||||
(history != undefined && history.length > 0) &&
|
||||
history.map(entry => (
|
||||
<li key={`${entry.revision_id}`} className="edit-history-list-element">
|
||||
<BuildingEditSummary
|
||||
@ -37,6 +94,10 @@ const ChangesPage = () => {
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
{
|
||||
paging?.id_for_older_query &&
|
||||
<Link to={`?before_id=${paging.id_for_older_query}`}>Show older changes</Link>
|
||||
}
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
|
@ -49,6 +49,11 @@ article .color-block {
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
}
|
||||
@media(min-width: 768px) {
|
||||
.main-col {
|
||||
min-width: 48em;
|
||||
}
|
||||
}
|
||||
hr {
|
||||
display: block;
|
||||
height: 1px;
|
||||
|
@ -34,3 +34,27 @@ export function isNullishOrEmpty(obj: any) {
|
||||
export function isEmptyArray(obj: any) {
|
||||
return Array.isArray(obj) && obj.length === 0;
|
||||
}
|
||||
|
||||
type AccessorFunction<T, V> = (obj: T) => V;
|
||||
|
||||
type CompareFunction<T> = (a: T, b: T) => number;
|
||||
|
||||
export function numAsc<T, V extends number | bigint>(accessor: AccessorFunction<T, V>): CompareFunction<T>{
|
||||
return (a: T, b: T) => Number(accessor(a) - accessor(b));
|
||||
}
|
||||
|
||||
export function numDesc<T, V extends number | bigint>(accessor: AccessorFunction<T, V>): CompareFunction<T> {
|
||||
return (a: T, b: T) => Number(accessor(b) - accessor(a));
|
||||
}
|
||||
|
||||
/**
|
||||
* As of Jan 2020, bigint literals e.g. 1n can only be used with TS target esnext
|
||||
* which then doesn't transpile the null conditional/coalescing operators and breaks on Node 12
|
||||
* So BigInt(1) needs to be used here
|
||||
* */
|
||||
export function incBigInt(bigStr: string): string {
|
||||
return bigStr == undefined ? bigStr : String(BigInt(bigStr) + BigInt(1));
|
||||
}
|
||||
export function decBigInt(bigStr: string): string {
|
||||
return bigStr == undefined ? bigStr : String(BigInt(bigStr) - BigInt(1));
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user