Skip to content

Commit ff86515

Browse files
authored
Merge pull request #975 from jboolean/pagination
Pagination for stories and outtakes
2 parents f6c1c7a + a9f9bdc commit ff86515

File tree

18 files changed

+521
-95
lines changed

18 files changed

+521
-95
lines changed

backend/cloneStories.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/bin/bash
2+
PGPASSWORD=$(aws ssm get-parameter --name fourtiesnyc-production-db-password --query Parameter.Value --output text --with-decryption) \
3+
pg_dump \
4+
--clean --if-exists --table stories \
5+
--host $(aws ssm get-parameter --name fourtiesnyc-production-db-host --query Parameter.Value --output text --with-decryption) \
6+
--port $(aws ssm get-parameter --name fourtiesnyc-production-db-port --query Parameter.Value --output text --with-decryption) \
7+
--username $(aws ssm get-parameter --name fourtiesnyc-production-db-username --query Parameter.Value --output text --with-decryption) \
8+
--dbname $(aws ssm get-parameter --name fourtiesnyc-production-db-database --query Parameter.Value --output text --with-decryption) \
9+
| \
10+
PGPASSWORD=$(aws ssm get-parameter --name fourtiesnyc-staging-db-password --query Parameter.Value --output text --with-decryption) \
11+
psql --host $(aws ssm get-parameter --name fourtiesnyc-staging-db-host --query Parameter.Value --output text --with-decryption) \
12+
--port $(aws ssm get-parameter --name fourtiesnyc-staging-db-port --query Parameter.Value --output text --with-decryption) \
13+
--username $(aws ssm get-parameter --name fourtiesnyc-staging-db-username --query Parameter.Value --output text --with-decryption) \
14+
--dbname $(aws ssm get-parameter --name fourtiesnyc-staging-db-database --query Parameter.Value --output text --with-decryption)

backend/src/api/photos/PhotosController.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ import {
1111
SuccessResponse,
1212
} from 'tsoa';
1313
import { getRepository, In } from 'typeorm';
14+
import Paginated from '../../business/pagination/Paginated';
15+
import mapPaginated from '../../business/utils/mapPaginated';
1416
import Photo from '../../entities/Photo';
1517
import Collection from '../../enum/Collection';
1618
import getLngLatForIdentifier from '../../repositories/getLngLatForIdentifier';
19+
import { getPaginated } from '../../repositories/paginationUtils';
1720
import { PhotoApiModel } from './PhotoApiModel';
1821
import photoToApi from './photoToApi';
1922

@@ -100,18 +103,36 @@ export class PhotosController extends Controller {
100103

101104
@Get('/outtake-summaries')
102105
public async getOuttakeSummaries(
103-
@Query() collection: Collection = Collection.FOURTIES
104-
): Promise<{ identifier: string }[]> {
106+
@Query() collection: Collection = Collection.FOURTIES,
107+
108+
// pagination
109+
@Query('pageToken') pageToken?: string,
110+
@Query('pageSize') pageSize = 100
111+
): Promise<Paginated<{ identifier: string }>> {
105112
const photoRepo = getRepository(Photo);
106113

107-
const photos = await photoRepo.find({
108-
where: { isOuttake: true, collection: collection },
109-
select: ['identifier'],
110-
order: {
111-
identifier: 'ASC',
114+
const qb = photoRepo
115+
.createQueryBuilder('photo')
116+
.select(['photo.identifier'])
117+
.where({ isOuttake: true, collection: collection });
118+
119+
const page = await getPaginated(
120+
qb,
121+
{
122+
key: 'identifier',
123+
sortDirection: 'ASC',
124+
getSerializedToken: (photo) => photo.identifier,
125+
deserializeToken: (token) => token,
112126
},
113-
});
114-
return photos.map(({ identifier }) => ({ identifier }));
127+
{
128+
pageToken,
129+
pageSize,
130+
}
131+
);
132+
133+
return mapPaginated(page, ({ identifier }: Pick<Photo, 'identifier'>) => ({
134+
identifier,
135+
}));
115136
}
116137

117138
@Get('/{identifier}')

backend/src/api/stories/StoriesController.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import {
1616
Security,
1717
} from 'tsoa';
1818

19+
import { UserData as NetlifyUserData } from 'gotrue-js';
1920
import { BadRequest, Forbidden, NotFound } from 'http-errors';
21+
import EmailCampaignService from '../../business/email/EmailCampaignService';
22+
import Paginated from '../../business/pagination/Paginated';
2023
import {
2124
backfillUserStoryEmails,
2225
onStateTransition,
@@ -26,6 +29,9 @@ import {
2629
verifyStoryToken,
2730
} from '../../business/stories/StoryTokenService';
2831
import { validateRecaptchaToken } from '../../business/utils/grecaptcha';
32+
import mapPaginated from '../../business/utils/mapPaginated';
33+
import normalizeEmail from '../../business/utils/normalizeEmail';
34+
import required from '../../business/utils/required';
2935
import Story from '../../entities/Story';
3036
import StoryState from '../../enum/StoryState';
3137
import StoryType from '../../enum/StoryType';
@@ -42,10 +48,6 @@ import {
4248
toDraftStoryResponse,
4349
toPublicStoryResponse,
4450
} from './storyToApi';
45-
import EmailCampaignService from '../../business/email/EmailCampaignService';
46-
import normalizeEmail from '../../business/utils/normalizeEmail';
47-
import { UserData as NetlifyUserData } from 'gotrue-js';
48-
import required from '../../business/utils/required';
4951

5052
function updateModelFromRequest(
5153
story: Story,
@@ -213,19 +215,29 @@ export class StoriesController extends Controller {
213215

214216
@Get('/')
215217
public async getStories(
216-
@Query('forPhotoIdentifier') identifier?: string
217-
): Promise<PublicStoryResponse[]> {
218-
let stories: Story[];
218+
@Query('forPhotoIdentifier') identifier?: string,
219+
220+
// pagination
221+
@Query('pageToken') pageToken?: string,
222+
@Query('pageSize') pageSize = 100
223+
): Promise<Paginated<PublicStoryResponse>> {
224+
let stories: Paginated<Story>;
225+
226+
const paginationInput = {
227+
pageToken,
228+
pageSize,
229+
};
219230

220231
if (identifier) {
221232
stories = await StoryRepository().findPublishedForPhotoIdentifier(
222-
identifier
233+
identifier,
234+
paginationInput
223235
);
224236
} else {
225-
stories = await StoryRepository().findPublished();
237+
stories = await StoryRepository().findPublished(paginationInput);
226238
}
227239

228-
return map(stories, toPublicStoryResponse);
240+
return mapPaginated(stories, toPublicStoryResponse);
229241
}
230242

231243
@Security('netlify', ['moderator'])
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
interface Paginated<T> {
2+
items: T[];
3+
total: number;
4+
hasNextPage: boolean;
5+
nextToken?: string;
6+
}
7+
8+
export default Paginated;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
interface PaginationInput {
2+
pageToken?: string;
3+
pageSize: number;
4+
}
5+
6+
export default PaginationInput;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { ObjectLiteral } from 'typeorm';
2+
3+
interface PaginationOptions<E extends ObjectLiteral> {
4+
key: string;
5+
sortDirection?: 'ASC' | 'DESC';
6+
getSerializedToken: (entity: E) => string;
7+
deserializeToken: (token: string) => unknown;
8+
}
9+
10+
export default PaginationOptions;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import map from 'lodash/map';
2+
import Paginated from '../pagination/Paginated';
3+
4+
export default function mapPaginated<I, O>(
5+
paginated: Paginated<I>,
6+
fn: (I) => O
7+
): Paginated<O> {
8+
return {
9+
...paginated,
10+
items: map<I, O>(paginated.items, fn),
11+
};
12+
}

backend/src/repositories/StoryRepository.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
import { Brackets, getRepository, In, IsNull, Repository } from 'typeorm';
2+
import Paginated from '../business/pagination/Paginated';
3+
import PaginationInput from '../business/pagination/PaginationInput';
24
import Story from '../entities/Story';
35
import StoryState from '../enum/StoryState';
46
import getLngLatForIdentifier from './getLngLatForIdentifier';
7+
import { getPaginated } from './paginationUtils';
58

69
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -- better type is inferred
710
const StoryRepository = () =>
811
getRepository(Story).extend({
912
async findPublishedForPhotoIdentifier(
1013
this: Repository<Story>,
11-
identifier: string
14+
identifier: string,
15+
pagination: PaginationInput
1216
) {
1317
// Get the lng,lat for this photo, so we can return stories
1418
// for this photo and stories for other photos in the same location
1519
const maybeLngLat = await getLngLatForIdentifier(identifier);
1620

17-
return this.createQueryBuilder('story')
21+
const qb = this.createQueryBuilder('story')
1822
.where({ state: StoryState.PUBLISHED })
1923
.andWhere(
2024
new Brackets((qb) => {
@@ -31,19 +35,39 @@ const StoryRepository = () =>
3135
)
3236
.leftJoinAndSelect('story.photo', 'photo')
3337
.leftJoinAndSelect('photo.effectiveAddress', 'effectiveAddress')
34-
.leftJoinAndSelect('photo.effectiveGeocode', 'effectiveGeocode')
35-
.orderBy('story.created_at', 'DESC')
36-
.getMany();
38+
.leftJoinAndSelect('photo.effectiveGeocode', 'effectiveGeocode');
39+
return getPaginated(
40+
qb,
41+
{
42+
key: 'createdAt',
43+
sortDirection: 'DESC',
44+
getSerializedToken: (story) => story.createdAt.toISOString(),
45+
deserializeToken: (token) => Date.parse(token),
46+
},
47+
pagination
48+
);
3749
},
3850

39-
async findPublished(this: Repository<Story>) {
40-
return this.createQueryBuilder('story')
51+
async findPublished(
52+
this: Repository<Story>,
53+
pagination: PaginationInput
54+
): Promise<Paginated<Story>> {
55+
const qb = this.createQueryBuilder('story')
4156
.where({ state: StoryState.PUBLISHED })
42-
.orderBy('story.created_at', 'DESC')
4357
.leftJoinAndSelect('story.photo', 'photo')
4458
.leftJoinAndSelect('photo.effectiveAddress', 'effectiveAddress')
45-
.leftJoinAndSelect('photo.effectiveGeocode', 'effectiveGeocode')
46-
.getMany();
59+
.leftJoinAndSelect('photo.effectiveGeocode', 'effectiveGeocode');
60+
61+
return getPaginated(
62+
qb,
63+
{
64+
key: 'createdAt',
65+
sortDirection: 'DESC',
66+
getSerializedToken: (story) => story.createdAt.toISOString(),
67+
deserializeToken: (token) => new Date(token),
68+
},
69+
pagination
70+
);
4771
},
4872

4973
/**
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import last from 'lodash/last';
2+
import {
3+
FindOperator,
4+
IsNull,
5+
LessThan,
6+
MoreThan,
7+
Not,
8+
ObjectLiteral,
9+
SelectQueryBuilder,
10+
} from 'typeorm';
11+
import Paginated from '../business/pagination/Paginated';
12+
import PaginationInput from '../business/pagination/PaginationInput';
13+
import PaginationOptions from '../business/pagination/PaginationOptions';
14+
import required from '../business/utils/required';
15+
16+
export async function getPaginated<E extends ObjectLiteral>(
17+
qb: SelectQueryBuilder<E>,
18+
{
19+
key,
20+
sortDirection,
21+
getSerializedToken,
22+
deserializeToken,
23+
}: PaginationOptions<E>,
24+
{ pageToken: nextToken, pageSize }: PaginationInput
25+
): Promise<Paginated<E>> {
26+
let filterOp: FindOperator<unknown> = Not(IsNull());
27+
if (nextToken) {
28+
const tokenDeserialized = deserializeToken(nextToken);
29+
filterOp =
30+
sortDirection === 'ASC'
31+
? MoreThan(tokenDeserialized)
32+
: LessThan(tokenDeserialized);
33+
}
34+
35+
const hasWhere = qb.expressionMap.wheres.length > 0;
36+
37+
// Note clone doesn't actually seem to do anything, so it's important to do count before adding where clauses
38+
const count = await qb.clone().getCount();
39+
40+
const results = await qb
41+
.clone()
42+
.orderBy(`${qb.alias}.${key}`, sortDirection)
43+
[hasWhere ? 'andWhere' : 'where']({
44+
[key]: filterOp,
45+
})
46+
.take(pageSize + 1)
47+
.getMany();
48+
49+
const items = results.slice(0, pageSize);
50+
const hasNextPage = results.length > pageSize;
51+
const nextNextToken = hasNextPage
52+
? getSerializedToken(required(last(items), 'last'))
53+
: undefined;
54+
return {
55+
items,
56+
total: count,
57+
hasNextPage,
58+
nextToken: nextNextToken,
59+
};
60+
}

0 commit comments

Comments
 (0)