Skip to content

Commit e90c46f

Browse files
authored
Add campaigns routes (safe-global#1551)
Adds the following endpoints to the service: GET /api/v1/locking/campaigns GET /api/v1/locking/campaigns/:campaignId
1 parent 0e72cfe commit e90c46f

File tree

10 files changed

+288
-1
lines changed

10 files changed

+288
-1
lines changed

src/domain/locking/entities/__tests__/campaign.builder.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { IBuilder, Builder } from '@/__tests__/builder';
2+
import { activityMetadataBuilder } from '@/domain/locking/entities/__tests__/activity-metadata.builder';
23
import { Campaign } from '@/domain/locking/entities/campaign.entity';
34
import { faker } from '@faker-js/faker';
45

@@ -9,5 +10,11 @@ export function campaignBuilder(): IBuilder<Campaign> {
910
.with('description', faker.lorem.sentence())
1011
.with('periodStart', faker.date.recent())
1112
.with('periodEnd', faker.date.future())
12-
.with('lastUpdated', faker.date.recent());
13+
.with('lastUpdated', faker.date.recent())
14+
.with(
15+
'activities',
16+
Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () =>
17+
activityMetadataBuilder().build(),
18+
),
19+
);
1320
}

src/domain/locking/entities/campaign.entity.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { buildPageSchema } from '@/domain/entities/schemas/page.schema.factory';
12
import { ActivityMetadataSchema } from '@/domain/locking/entities/activity-metadata.entity';
23
import { z } from 'zod';
34

@@ -12,3 +13,5 @@ export const CampaignSchema = z.object({
1213
lastUpdated: z.coerce.date(),
1314
activities: z.array(ActivityMetadataSchema).nullish().default(null),
1415
});
16+
17+
export const CampaignPageSchema = buildPageSchema(CampaignSchema);

src/domain/locking/locking.repository.interface.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import { Page } from '@/domain/entities/page.entity';
2+
import { Campaign } from '@/domain/locking/entities/campaign.entity';
23
import { LockingEvent } from '@/domain/locking/entities/locking-event.entity';
34
import { Rank } from '@/domain/locking/entities/rank.entity';
45

56
export const ILockingRepository = Symbol('ILockingRepository');
67

78
export interface ILockingRepository {
9+
getCampaignById(campaignId: string): Promise<Campaign>;
10+
11+
getCampaigns(args: {
12+
limit?: number;
13+
offset?: number;
14+
}): Promise<Page<Campaign>>;
15+
816
getRank(safeAddress: `0x${string}`): Promise<Rank>;
917

1018
getLeaderboard(args: {

src/domain/locking/locking.repository.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { Page } from '@/domain/entities/page.entity';
22
import { ILockingApi } from '@/domain/interfaces/locking-api.interface';
3+
import {
4+
Campaign,
5+
CampaignPageSchema,
6+
} from '@/domain/locking/entities/campaign.entity';
37
import { LockingEvent } from '@/domain/locking/entities/locking-event.entity';
48
import { Rank } from '@/domain/locking/entities/rank.entity';
59
import { LockingEventPageSchema } from '@/domain/locking/entities/schemas/locking-event.schema';
@@ -17,6 +21,18 @@ export class LockingRepository implements ILockingRepository {
1721
private readonly lockingApi: ILockingApi,
1822
) {}
1923

24+
async getCampaignById(campaignId: string): Promise<Campaign> {
25+
return this.lockingApi.getCampaignById(campaignId);
26+
}
27+
28+
async getCampaigns(args: {
29+
limit?: number | undefined;
30+
offset?: number | undefined;
31+
}): Promise<Page<Campaign>> {
32+
const page = await this.lockingApi.getCampaigns(args);
33+
return CampaignPageSchema.parse(page);
34+
}
35+
2036
async getRank(safeAddress: `0x${string}`): Promise<Rank> {
2137
const rank = await this.lockingApi.getRank(safeAddress);
2238
return RankSchema.parse(rank);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { ActivityMetadata as DomainActivityMetadata } from '@/domain/locking/entities/activity-metadata.entity';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
4+
export class ActivityMetadata implements DomainActivityMetadata {
5+
@ApiProperty()
6+
campaignId!: string;
7+
@ApiProperty()
8+
name!: string;
9+
@ApiProperty()
10+
description!: string;
11+
@ApiProperty()
12+
maxPoints!: string;
13+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Campaign as DomainCampaign } from '@/domain/locking/entities/campaign.entity';
2+
import { ActivityMetadata } from '@/routes/locking/entities/activity-metadata.entity';
3+
import { ApiProperty } from '@nestjs/swagger';
4+
5+
export class Campaign implements DomainCampaign {
6+
@ApiProperty()
7+
campaignId!: string;
8+
@ApiProperty()
9+
name!: string;
10+
@ApiProperty()
11+
description!: string;
12+
@ApiProperty({ type: String })
13+
periodStart!: Date;
14+
@ApiProperty({ type: String })
15+
periodEnd!: Date;
16+
@ApiProperty({ type: String })
17+
lastUpdated!: Date;
18+
@ApiProperty({ type: [ActivityMetadata] })
19+
activities!: ActivityMetadata[] | null;
20+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Page } from '@/routes/common/entities/page.entity';
2+
import { Campaign } from '@/routes/locking/entities/campaign.entity';
3+
import { ApiProperty } from '@nestjs/swagger';
4+
5+
export class CampaignPage extends Page<Campaign> {
6+
@ApiProperty({ type: [Campaign] })
7+
results!: Array<Campaign>;
8+
}

src/routes/locking/locking.controller.spec.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import { rankBuilder } from '@/domain/locking/entities/__tests__/rank.builder';
3131
import { PaginationData } from '@/routes/common/pagination/pagination.data';
3232
import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module';
3333
import { QueuesApiModule } from '@/datasources/queues/queues-api.module';
34+
import { campaignBuilder } from '@/domain/locking/entities/__tests__/campaign.builder';
35+
import { Campaign } from '@/domain/locking/entities/campaign.entity';
3436

3537
describe('Locking (Unit)', () => {
3638
let app: INestApplication;
@@ -67,6 +69,165 @@ describe('Locking (Unit)', () => {
6769
await app.close();
6870
});
6971

72+
describe('GET campaign', () => {
73+
it('should get the campaign', async () => {
74+
const campaign = campaignBuilder().build();
75+
networkService.get.mockImplementation(({ url }) => {
76+
switch (url) {
77+
case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}`:
78+
return Promise.resolve({ data: campaign, status: 200 });
79+
default:
80+
return Promise.reject(`No matching rule for url: ${url}`);
81+
}
82+
});
83+
84+
await request(app.getHttpServer())
85+
.get(`/v1/locking/campaigns/${campaign.campaignId}`)
86+
.expect(200)
87+
.expect({
88+
...campaign,
89+
periodStart: campaign.periodStart.toISOString(),
90+
periodEnd: campaign.periodEnd.toISOString(),
91+
lastUpdated: campaign.lastUpdated.toISOString(),
92+
});
93+
});
94+
95+
it('should get the list of campaigns', async () => {
96+
const campaignsPage = pageBuilder<Campaign>()
97+
.with('results', [campaignBuilder().build()])
98+
.with('count', 1)
99+
.with('previous', null)
100+
.with('next', null)
101+
.build();
102+
networkService.get.mockImplementation(({ url }) => {
103+
switch (url) {
104+
case `${lockingBaseUri}/api/v1/campaigns`:
105+
return Promise.resolve({ data: campaignsPage, status: 200 });
106+
default:
107+
return Promise.reject(`No matching rule for url: ${url}`);
108+
}
109+
});
110+
111+
await request(app.getHttpServer())
112+
.get(`/v1/locking/campaigns`)
113+
.expect(200)
114+
.expect({
115+
count: 1,
116+
next: null,
117+
previous: null,
118+
results: campaignsPage.results.map((campaign) => ({
119+
...campaign,
120+
periodStart: campaign.periodStart.toISOString(),
121+
periodEnd: campaign.periodEnd.toISOString(),
122+
lastUpdated: campaign.lastUpdated.toISOString(),
123+
})),
124+
});
125+
});
126+
127+
it('should validate the list of campaigns', async () => {
128+
const invalidCampaigns = [{ invalid: 'campaign' }];
129+
const campaignsPage = pageBuilder()
130+
.with('results', invalidCampaigns)
131+
.with('count', 1)
132+
.with('previous', null)
133+
.with('next', null)
134+
.build();
135+
networkService.get.mockImplementation(({ url }) => {
136+
switch (url) {
137+
case `${lockingBaseUri}/api/v1/campaigns`:
138+
return Promise.resolve({ data: campaignsPage, status: 200 });
139+
default:
140+
return Promise.reject(`No matching rule for url: ${url}`);
141+
}
142+
});
143+
144+
await request(app.getHttpServer())
145+
.get(`/v1/locking/campaigns`)
146+
.expect(500)
147+
.expect({
148+
statusCode: 500,
149+
message: 'Internal server error',
150+
});
151+
});
152+
153+
it('should forward the pagination parameters', async () => {
154+
const limit = faker.number.int({ min: 1, max: 10 });
155+
const offset = faker.number.int({ min: 1, max: 10 });
156+
const campaignsPage = pageBuilder<Campaign>()
157+
.with('results', [campaignBuilder().build()])
158+
.with('count', 1)
159+
.with('previous', null)
160+
.with('next', null)
161+
.build();
162+
networkService.get.mockImplementation(({ url }) => {
163+
switch (url) {
164+
case `${lockingBaseUri}/api/v1/campaigns`:
165+
return Promise.resolve({ data: campaignsPage, status: 200 });
166+
default:
167+
return Promise.reject(`No matching rule for url: ${url}`);
168+
}
169+
});
170+
171+
await request(app.getHttpServer())
172+
.get(
173+
`/v1/locking/campaigns?cursor=limit%3D${limit}%26offset%3D${offset}`,
174+
)
175+
.expect(200)
176+
.expect({
177+
count: 1,
178+
next: null,
179+
previous: null,
180+
results: campaignsPage.results.map((campaign) => ({
181+
...campaign,
182+
periodStart: campaign.periodStart.toISOString(),
183+
periodEnd: campaign.periodEnd.toISOString(),
184+
lastUpdated: campaign.lastUpdated.toISOString(),
185+
})),
186+
});
187+
188+
expect(networkService.get).toHaveBeenCalledWith({
189+
url: `${lockingBaseUri}/api/v1/campaigns`,
190+
networkRequest: {
191+
params: {
192+
limit,
193+
offset,
194+
},
195+
},
196+
});
197+
});
198+
199+
it('should forward errors from the service', async () => {
200+
const statusCode = faker.internet.httpStatusCode({
201+
types: ['clientError', 'serverError'],
202+
});
203+
const errorMessage = faker.word.words();
204+
networkService.get.mockImplementation(({ url }) => {
205+
switch (url) {
206+
case `${lockingBaseUri}/api/v1/campaigns`:
207+
return Promise.reject(
208+
new NetworkResponseError(
209+
new URL(`${lockingBaseUri}/api/v1/campaigns`),
210+
{
211+
status: statusCode,
212+
} as Response,
213+
{ message: errorMessage, status: statusCode },
214+
),
215+
);
216+
default:
217+
return Promise.reject(`No matching rule for url: ${url}`);
218+
}
219+
});
220+
221+
await request(app.getHttpServer())
222+
.get(`/v1/locking/campaigns`)
223+
.expect(statusCode)
224+
.expect({
225+
message: errorMessage,
226+
code: statusCode,
227+
});
228+
});
229+
});
230+
70231
describe('GET rank', () => {
71232
it('should get the rank', async () => {
72233
const rank = rankBuilder().build();

src/routes/locking/locking.controller.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { AddressSchema } from '@/validation/entities/schemas/address.schema';
99
import { ValidationPipe } from '@/validation/pipes/validation.pipe';
1010
import { Controller, Get, Param } from '@nestjs/common';
1111
import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger';
12+
import { Campaign } from '@/routes/locking/entities/campaign.entity';
13+
import { CampaignPage } from '@/routes/locking/entities/campaign.page.entity';
1214

1315
@ApiTags('locking')
1416
@Controller({
@@ -18,6 +20,28 @@ import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger';
1820
export class LockingController {
1921
constructor(private readonly lockingService: LockingService) {}
2022

23+
@ApiOkResponse({ type: Campaign })
24+
@Get('/campaigns/:campaignId')
25+
async getCampaignById(
26+
@Param('campaignId') campaignId: string,
27+
): Promise<Campaign> {
28+
return this.lockingService.getCampaignById(campaignId);
29+
}
30+
31+
@ApiOkResponse({ type: CampaignPage })
32+
@ApiQuery({
33+
name: 'cursor',
34+
required: false,
35+
type: String,
36+
})
37+
@Get('/campaigns')
38+
async getCampaigns(
39+
@RouteUrlDecorator() routeUrl: URL,
40+
@PaginationDataDecorator() paginationData: PaginationData,
41+
): Promise<CampaignPage> {
42+
return this.lockingService.getCampaigns({ routeUrl, paginationData });
43+
}
44+
2145
@ApiOkResponse({ type: Rank })
2246
@Get('/leaderboard/rank/:safeAddress')
2347
async getRank(

src/routes/locking/locking.service.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Page } from '@/domain/entities/page.entity';
2+
import { Campaign } from '@/domain/locking/entities/campaign.entity';
23
import { LockingEvent } from '@/domain/locking/entities/locking-event.entity';
34
import { Rank } from '@/domain/locking/entities/rank.entity';
45
import { ILockingRepository } from '@/domain/locking/locking.repository.interface';
@@ -15,6 +16,32 @@ export class LockingService {
1516
private readonly lockingRepository: ILockingRepository,
1617
) {}
1718

19+
async getCampaignById(campaignId: string): Promise<Campaign> {
20+
return this.lockingRepository.getCampaignById(campaignId);
21+
}
22+
23+
async getCampaigns(args: {
24+
routeUrl: URL;
25+
paginationData: PaginationData;
26+
}): Promise<Page<Campaign>> {
27+
const result = await this.lockingRepository.getCampaigns(
28+
args.paginationData,
29+
);
30+
31+
const nextUrl = cursorUrlFromLimitAndOffset(args.routeUrl, result.next);
32+
const previousUrl = cursorUrlFromLimitAndOffset(
33+
args.routeUrl,
34+
result.previous,
35+
);
36+
37+
return {
38+
count: result.count,
39+
next: nextUrl?.toString() ?? null,
40+
previous: previousUrl?.toString() ?? null,
41+
results: result.results,
42+
};
43+
}
44+
1845
async getRank(safeAddress: `0x${string}`): Promise<Rank> {
1946
return this.lockingRepository.getRank(safeAddress);
2047
}

0 commit comments

Comments
 (0)