Skip to content

Commit

Permalink
Add pagination to /event-log
Browse files Browse the repository at this point in the history
Also:
- Readd /event-log link to admin page.
- Support using query string parameters in query routes.
  • Loading branch information
chromy committed Nov 30, 2024
1 parent ca79ae7 commit c649dda
Show file tree
Hide file tree
Showing 10 changed files with 121 additions and 32 deletions.
Binary file modified bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"pino-http": "^10.0.0",
"pino-pretty": "^11.0.0",
"pubsub-js": "^1.9.4",
"qs": "^6.13.1",
"sanitize-html": "^2.13.0",
"uuid": "^9.0.0",
"validator": "^13.7.0"
Expand Down
19 changes: 17 additions & 2 deletions src/http/query-get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,26 @@ import {Request, Response} from 'express';
import {getUserFromSession} from '../authentication';
import {StatusCodes} from 'http-status-codes';
import {oopsPage, pageTemplate} from '../templates';
import {Query} from '../queries/query';
import {Query, Params} from '../queries/query';
import {logInPath} from '../authentication/auth-routes';
import {CompleteHtmlDocument, sanitizeString} from '../types/html';
import * as O from 'fp-ts/Option';
import {match} from '../types/tagged-union';
import {ParsedQs} from 'qs';

// req.query has a complicated type:
// type ParsedQs = { [key: string]: undefined | string | string[] | ParsedQs | ParsedQs[] };
// Here we ignore the complex cases and filter down to Record<string, string>
// See https://evanhahn.com/gotchas-with-express-query-parsing-and-how-to-avoid-them/
const simplifyExpressQuery = (qs: ParsedQs) => {
const params: Params = {};
for (const [k, v] of Object.entries(qs)) {
if (typeof v === 'string') {
params[k] = v;
}
}
return params;
};

export const queryGet =
(deps: Dependencies, query: Query) =>
Expand All @@ -26,7 +41,7 @@ export const queryGet =
return;
}
await pipe(
query(deps)(user.value, req.params),
query(deps)(user.value, req.params, simplifyExpressQuery(req.query)),
TE.matchW(
failure => {
deps.logger.error(failure, 'Failed respond to a query');
Expand Down
5 changes: 4 additions & 1 deletion src/queries/admin/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export const render = () => html`
<a href="/members/failed-imports">View failed member number imports</a>
</li>
<li>
<a href="/event-log.csv">View log of all actions taken</a>
<a href="/event-log">View a log of all actions taken</a>
</li>
<li>
<a href="/event-log.csv">Download a log of all actions taken</a>
</li>
<li><a href="/members/create">Link an email and number</a></li>
<li><a href="/training-status.csv">Download current owners and trainers</li>
Expand Down
66 changes: 48 additions & 18 deletions src/queries/log/construct-view-model.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,58 @@
import {Params} from '../query';
import {pipe} from 'fp-ts/lib/function';
import {User} from '../../types';
import {DomainEvent, User} from '../../types';
import {Dependencies} from '../../dependencies';
import * as TE from 'fp-ts/TaskEither';
import {ViewModel} from './view-model';
import {ViewModel, LogSearch} from './view-model';
import * as RA from 'fp-ts/ReadonlyArray';
import {readModels} from '../../read-models';
import {failureWithStatus} from '../../types/failure-with-status';
import {StatusCodes} from 'http-status-codes';

export const constructViewModel = (deps: Dependencies) => (user: User) =>
pipe(
deps.getAllEvents(),
TE.filterOrElse(readModels.superUsers.is(user.memberNumber), () =>
failureWithStatus(
'You do not have the necessary permission to see this page.',
StatusCodes.FORBIDDEN
)()
),
TE.map(RA.reverse),
TE.map(
events =>
({
function parseQueryToLogSearch(query: Params): LogSearch {
const rawOffset = query['offset'];
const offsetAsNumber = Number(rawOffset);
const offset = isNaN(offsetAsNumber) ? 0 : offsetAsNumber;

const maxLimit = 100;
const rawLimit = query['limit'];
const limitAsNumber = Number(rawLimit);
const limit = isNaN(limitAsNumber) ? maxLimit : limitAsNumber;

return {
offset: Math.max(0, offset),
limit: Math.min(maxLimit, limit),
};
}

const applyLogSearch =
(search: LogSearch) => (events: ReadonlyArray<DomainEvent>) =>
pipe(events, events => {
const start = search.offset;
const end = search.offset + search.limit;
return events.slice(start, end);
});

export const constructViewModel =
(deps: Dependencies) => (user: User) => (queryParams: Params) =>
pipe(
deps.getAllEvents(),
TE.filterOrElse(readModels.superUsers.is(user.memberNumber), () =>
failureWithStatus(
'You do not have the necessary permission to see this page.',
StatusCodes.FORBIDDEN
)()
),
TE.map(RA.reverse),
TE.map(allEvents => {
const count = allEvents.length;
const search = parseQueryToLogSearch(queryParams);
const events = applyLogSearch(search)(allEvents);
return {
count,
events,
user,
}) satisfies ViewModel
)
);
search,
} satisfies ViewModel;
})
);
9 changes: 5 additions & 4 deletions src/queries/log/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import {pipe} from 'fp-ts/lib/function';
import * as TE from 'fp-ts/TaskEither';
import {render} from './render';
import {constructViewModel} from './construct-view-model';
import {Query} from '../query';
import {Query, Params} from '../query';
import {safe, toLoggedInContent} from '../../types/html';
import {User} from '../../types';

export const log: Query = deps => user =>
export const log: Query = deps => (user: User, params: Params, query: Params) =>
pipe(
user,
constructViewModel(deps),
query,
constructViewModel(deps)(user),
TE.map(render),
TE.map(toLoggedInContent(safe('Event Log')))
);
37 changes: 34 additions & 3 deletions src/queries/log/render.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import {pipe} from 'fp-ts/lib/function';
import * as RA from 'fp-ts/ReadonlyArray';
import {html, joinHtml, sanitizeString} from '../../types/html';
import {ViewModel} from './view-model';
import {html, safe, joinHtml, sanitizeString} from '../../types/html';
import {ViewModel, LogSearch} from './view-model';
import {DomainEvent} from '../../types';
import {inspect} from 'node:util';
import {displayDate} from '../../templates/display-date';
import {DateTime} from 'luxon';
import {renderActor} from '../../types/actor';
import * as qs from 'qs';

const renderPayload = (event: DomainEvent) =>
// eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-vars
Expand Down Expand Up @@ -40,8 +41,38 @@ const renderLog = (log: ViewModel['events']) =>
`
);

const searchToLink = (search: LogSearch) => {
return safe(`/event-log?${qs.stringify(search)}`);
};

const paginationAmount = (viewModel: ViewModel) => viewModel.search.limit ?? 10;

const renderPrevLink = (viewModel: ViewModel) =>
pipe(
viewModel.search,
({offset, ...args}) => ({
offset: Math.max(offset - paginationAmount(viewModel), 0),
...args,
}),
searchToLink,
link => html`<a href=${link}>Prev</a>`
);

const renderNextLink = (viewModel: ViewModel) =>
pipe(
viewModel.search,
({offset, ...args}) => ({
offset: offset + paginationAmount(viewModel),
...args,
}),
searchToLink,
link => html`<a href=${link}>Next</a>`
);

export const render = (viewModel: ViewModel) => html`
<h1>Event log</h1>
<p>Most recent at top</p>
<p>Showing ${viewModel.events.length} of ${viewModel.count} events.</p>
${renderLog(viewModel.events)}
<p>${renderPrevLink(viewModel)}</p>
<p>${renderNextLink(viewModel)}</p>
`;
7 changes: 7 additions & 0 deletions src/queries/log/view-model.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import {DomainEvent, User} from '../../types';

export interface LogSearch {
offset: number;
limit: number;
}

export type ViewModel = {
user: User;
count: number;
search: LogSearch;
events: ReadonlyArray<DomainEvent>;
};
5 changes: 3 additions & 2 deletions src/queries/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import {User, HttpResponse} from '../types';
import {FailureWithStatus} from '../types/failure-with-status';
import * as TE from 'fp-ts/TaskEither';

type Params = Record<string, string>;
export type Params = Record<string, string>;

export type Query = (
deps: Dependencies
) => (
user: User,
params: Params
params: Params,
queryParams: Params
) => TE.TaskEither<FailureWithStatus, HttpResponse>;
4 changes: 2 additions & 2 deletions tests/queries/log/construct-view-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ describe('construct-view-model', () => {

it('does not show the event log', async () => {
const failure = await pipe(
arbitraryUser(),
constructViewModel(deps),
{},
constructViewModel(deps)(arbitraryUser()),
T.map(getLeftOrFail)
)();

Expand Down

0 comments on commit c649dda

Please sign in to comment.