Skip to content

Commit 1577dd8

Browse files
committed
feat: add GET /api/creators/:address/campaigns endpoint
Adds a creator-scoped campaign listing endpoint that reuses the same filtering, sorting, and pagination logic as GET /api/campaigns. - Add `creator` filter to ListCampaignsOptions in campaignStore.listCampaigns - Extract buildCampaignListOptions/buildCampaignListResponseBody helpers so /api/campaigns and /api/creators/:address/campaigns share identical query parsing and response shaping - Validate the :address path param against the Stellar account ID format (consistent with how creator/contributor addresses are validated elsewhere in the API), returning 400 VALIDATION_ERROR on malformed input - Unknown but well-formed addresses return an empty data array with 200 - Register the new path in the OpenAPI spec - Add endpoint test coverage: invalid address, unknown address, creator filtering, shared filters/pagination behavior Closes #557
1 parent 3e2dd95 commit 1577dd8

4 files changed

Lines changed: 236 additions & 61 deletions

File tree

backend/src/api.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,99 @@ describe('Campaign maxPerContributor Field', () => {
430430
});
431431
});
432432

433+
describe('GET /api/creators/:address/campaigns', () => {
434+
async function get(apiPath: string) {
435+
const response = await fetch(`${baseUrl}${apiPath}`);
436+
const data = await response.json().catch(() => null);
437+
return { status: response.status, data };
438+
}
439+
440+
const OTHER_CREATOR = `G${'D'.repeat(55)}`;
441+
442+
it('returns 400 for a malformed address', async () => {
443+
const res = await get('/api/creators/not-a-valid-address/campaigns');
444+
expect(res.status).toBe(400);
445+
expect(res.data.error.code).toBe('VALIDATION_ERROR');
446+
});
447+
448+
it('returns 400 for an address of the wrong length', async () => {
449+
const res = await get(`/api/creators/${'G' + 'A'.repeat(10)}/campaigns`);
450+
expect(res.status).toBe(400);
451+
expect(res.data.error.code).toBe('VALIDATION_ERROR');
452+
});
453+
454+
it('returns an empty array for a well-formed address with no campaigns', async () => {
455+
const res = await get(`/api/creators/${CREATOR}/campaigns`);
456+
expect(res.status).toBe(200);
457+
expect(res.data.data).toEqual([]);
458+
expect(res.data.pagination.total).toBe(0);
459+
});
460+
461+
it('returns only campaigns created by the given address', async () => {
462+
const now = Math.floor(Date.now() / 1000);
463+
464+
const mine = await post('/api/campaigns', {
465+
creator: CREATOR,
466+
title: 'My campaign',
467+
description: 'Campaign owned by the queried creator address',
468+
acceptedTokens: ['USDC'],
469+
targetAmount: 100,
470+
deadline: now + 3600,
471+
});
472+
expect(mine.status).toBe(201);
473+
474+
const other = await post('/api/campaigns', {
475+
creator: OTHER_CREATOR,
476+
title: 'Someone else campaign',
477+
description: 'Campaign owned by a different creator address',
478+
acceptedTokens: ['USDC'],
479+
targetAmount: 100,
480+
deadline: now + 3600,
481+
});
482+
expect(other.status).toBe(201);
483+
484+
const res = await get(`/api/creators/${CREATOR}/campaigns`);
485+
expect(res.status).toBe(200);
486+
expect(res.data.data).toHaveLength(1);
487+
expect(res.data.data[0].creator).toBe(CREATOR);
488+
expect(res.data.data[0].id).toBe(mine.data.data.id);
489+
// Response schema matches GET /api/campaigns (data + pagination + progress per item)
490+
expect(res.data.data[0].progress).toBeDefined();
491+
expect(res.data.pagination).toMatchObject({ total: 1, page: 1 });
492+
});
493+
494+
it('supports the same filters and pagination as GET /api/campaigns', async () => {
495+
const now = Math.floor(Date.now() / 1000);
496+
497+
for (const asset of ['USDC', 'XLM']) {
498+
const res = await post('/api/campaigns', {
499+
creator: CREATOR,
500+
title: `${asset} campaign`,
501+
description: `Campaign accepting ${asset} for pagination test`,
502+
acceptedTokens: [asset],
503+
targetAmount: 100,
504+
deadline: now + 3600,
505+
});
506+
expect(res.status).toBe(201);
507+
}
508+
509+
const filtered = await get(`/api/creators/${CREATOR}/campaigns?asset=XLM`);
510+
expect(filtered.status).toBe(200);
511+
expect(filtered.data.data.every((c: { assetCode: string }) => c.assetCode === 'XLM')).toBe(true);
512+
513+
const paginated = await get(`/api/creators/${CREATOR}/campaigns?page=1&limit=1`);
514+
expect(paginated.status).toBe(200);
515+
expect(paginated.data.data).toHaveLength(1);
516+
expect(paginated.data.pagination).toMatchObject({ page: 1, limit: 1, total: 2 });
517+
});
518+
519+
it('returns 400 for invalid pagination parameters, matching GET /api/campaigns', async () => {
520+
const res = await get(`/api/creators/${CREATOR}/campaigns?page=1`);
521+
expect(res.status).toBe(400);
522+
expect(res.data.error.code).toBe('VALIDATION_ERROR');
523+
});
524+
});
525+
433526
describe('GET /api/stats', () => {
434527
it('returns aggregate metrics in the correct format', async () => {
435528
const res = await get('/api/stats');

backend/src/index.ts

Lines changed: 92 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,12 @@ import {
5555
parsePledgeListPaginationQuery,
5656
reconcilePledgePayloadSchema,
5757
refundPayloadSchema,
58+
stellarAccountIdSchema,
5859
zodIssuesToErrorMessage,
5960
zodIssuesToValidationIssues,
6061
parseCampaignListQuery,
6162
normalizeQueryValue,
63+
type CampaignListQueryParams,
6264
} from './validation/schemas';
6365
import { generateOpenApiDocument } from './openapi';
6466
import { logError, logInfo } from './logger';
@@ -232,6 +234,30 @@ function parseCampaignId(
232234
return { ok: true, value: parsed.data };
233235
}
234236

237+
function parseCreatorAddress(
238+
addressRaw: unknown,
239+
): { ok: true; value: string } | { ok: false; issues: z.ZodIssue[] } {
240+
if (typeof addressRaw !== 'string') {
241+
return {
242+
ok: false,
243+
issues: [
244+
{
245+
code: 'custom',
246+
message: 'address must be a string.',
247+
path: ['address'],
248+
},
249+
],
250+
};
251+
}
252+
253+
const parsed = stellarAccountIdSchema.safeParse(addressRaw);
254+
if (!parsed.success) {
255+
return { ok: false, issues: parsed.error.issues };
256+
}
257+
258+
return { ok: true, value: parsed.data };
259+
}
260+
235261
export function normalizeAssetFilter(assetRaw: unknown): string | undefined {
236262
const asset = normalizeQueryValue(assetRaw)?.toUpperCase();
237263
if (!asset) {
@@ -388,32 +414,10 @@ app.get('/api/health/deep', applyRateLimit(1000), async (_req: Request, res: Res
388414
}
389415
});
390416

391-
app.get('/api/campaigns', (req: Request, res: Response) => {
392-
const queryResult = parseCampaignListQuery(req.query as Record<string, unknown>);
393-
if (!queryResult.ok) {
394-
sendValidationError(queryResult.issues);
395-
}
396-
397-
const params = queryResult.data;
398-
399-
// Build a stable cache key from the sorted query string
400-
const qs = Object.keys(req.query as Record<string, unknown>)
401-
.sort()
402-
.map((k) => `${k}=${(req.query as Record<string, unknown>)[k]}`)
403-
.join('&');
404-
const cacheKey = buildCampaignCacheKey(qs);
405-
406-
const cached = getCampaignCacheEntry(cacheKey);
407-
if (cached) {
408-
const cachedData = JSON.parse(cached);
409-
res.setHeader('Cache-Control', 'max-age=5');
410-
res.setHeader('X-Cache', 'HIT');
411-
res.setHeader('X-Total-Count', String(cachedData.pagination.total));
412-
res.setHeader('Content-Type', 'application/json');
413-
res.send(cached);
414-
return;
415-
}
416-
417+
function buildCampaignListOptions(
418+
params: CampaignListQueryParams,
419+
extra?: Partial<ListCampaignsOptions>,
420+
): ListCampaignsOptions {
417421
const listOptions: ListCampaignsOptions = {
418422
searchQuery: params.search || params.q,
419423
assetCodes: params.asset,
@@ -423,12 +427,19 @@ app.get('/api/campaigns', (req: Request, res: Response) => {
423427
order: params.order,
424428
createdAfter: params.createdAfter,
425429
createdBefore: params.createdBefore,
430+
...extra,
426431
};
427432
if (params.page !== undefined) {
428433
listOptions.page = params.page;
429434
listOptions.limit = params.limit;
430435
}
436+
return listOptions;
437+
}
431438

439+
function buildCampaignListResponseBody(
440+
params: CampaignListQueryParams,
441+
listOptions: ListCampaignsOptions,
442+
): { totalCount: number; body: string } {
432443
const { campaigns, totalCount } = listCampaigns(listOptions);
433444

434445
const data = campaigns.map((campaign) => ({
@@ -441,7 +452,7 @@ app.get('/api/campaigns', (req: Request, res: Response) => {
441452
const totalPages =
442453
params.limit === undefined || limit <= 0 ? 1 : Math.max(1, Math.ceil(totalCount / limit));
443454

444-
const responseBody = JSON.stringify({
455+
const body = JSON.stringify({
445456
data,
446457
pagination: {
447458
total: totalCount,
@@ -451,13 +462,65 @@ app.get('/api/campaigns', (req: Request, res: Response) => {
451462
},
452463
});
453464

454-
setCampaignCacheEntry(cacheKey, responseBody);
465+
return { totalCount, body };
466+
}
467+
468+
app.get('/api/campaigns', (req: Request, res: Response) => {
469+
const queryResult = parseCampaignListQuery(req.query as Record<string, unknown>);
470+
if (!queryResult.ok) {
471+
sendValidationError(queryResult.issues);
472+
}
473+
474+
const params = queryResult.data;
475+
476+
// Build a stable cache key from the sorted query string
477+
const qs = Object.keys(req.query as Record<string, unknown>)
478+
.sort()
479+
.map((k) => `${k}=${(req.query as Record<string, unknown>)[k]}`)
480+
.join('&');
481+
const cacheKey = buildCampaignCacheKey(qs);
482+
483+
const cached = getCampaignCacheEntry(cacheKey);
484+
if (cached) {
485+
const cachedData = JSON.parse(cached);
486+
res.setHeader('Cache-Control', 'max-age=5');
487+
res.setHeader('X-Cache', 'HIT');
488+
res.setHeader('X-Total-Count', String(cachedData.pagination.total));
489+
res.setHeader('Content-Type', 'application/json');
490+
res.send(cached);
491+
return;
492+
}
493+
494+
const listOptions = buildCampaignListOptions(params);
495+
const { totalCount, body } = buildCampaignListResponseBody(params, listOptions);
496+
497+
setCampaignCacheEntry(cacheKey, body);
455498

456499
res.setHeader('Cache-Control', 'max-age=5');
457500
res.setHeader('X-Cache', 'MISS');
458501
res.setHeader('X-Total-Count', String(totalCount));
459502
res.setHeader('Content-Type', 'application/json');
460-
res.send(responseBody);
503+
res.send(body);
504+
});
505+
506+
app.get('/api/creators/:address/campaigns', (req: Request, res: Response) => {
507+
const parsedAddress = parseCreatorAddress(req.params.address);
508+
if (!parsedAddress.ok) {
509+
sendValidationError(parsedAddress.issues);
510+
}
511+
512+
const queryResult = parseCampaignListQuery(req.query as Record<string, unknown>);
513+
if (!queryResult.ok) {
514+
sendValidationError(queryResult.issues);
515+
}
516+
517+
const params = queryResult.data;
518+
const listOptions = buildCampaignListOptions(params, { creator: parsedAddress.value });
519+
const { totalCount, body } = buildCampaignListResponseBody(params, listOptions);
520+
521+
res.setHeader('X-Total-Count', String(totalCount));
522+
res.setHeader('Content-Type', 'application/json');
523+
res.send(body);
461524
});
462525

463526
app.get('/api/campaigns/:id', (req: Request, res: Response) => {

backend/src/openapi.ts

Lines changed: 46 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -463,45 +463,39 @@ registry.registerPath({
463463
},
464464
});
465465

466+
const campaignListQuerySchema = z.object({
467+
page: z.coerce
468+
.number()
469+
.int()
470+
.min(1)
471+
.optional()
472+
.openapi({ description: 'Page number (requires limit).' }),
473+
limit: z.coerce
474+
.number()
475+
.int()
476+
.min(1)
477+
.max(100)
478+
.optional()
479+
.openapi({ description: 'Items per page (requires page).' }),
480+
q: z.string().optional().openapi({ description: 'Search query (title, creator, or id).' }),
481+
search: z.string().optional().openapi({ description: 'Alias for q.' }),
482+
asset: z.string().optional().openapi({ description: 'Comma-separated list of asset codes.' }),
483+
status: z.enum(['open', 'funded', 'claimed', 'failed']).optional(),
484+
sort: z.enum(['createdAt', 'deadline', 'pledgedAmount', 'targetAmount']).optional(),
485+
order: z.enum(['asc', 'desc']).optional(),
486+
includeDeleted: z.enum(['true', 'false']).optional(),
487+
createdAfter: z.string().datetime().optional().openapi({ description: 'ISO 8601 timestamp.' }),
488+
createdBefore: z.string().datetime().optional().openapi({ description: 'ISO 8601 timestamp.' }),
489+
});
490+
466491
registry.registerPath({
467492
method: 'get',
468493
path: '/api/campaigns',
469494
tags: ['Campaigns'],
470495
summary: 'List campaigns',
471496
description: 'List campaigns with optional filtering, sorting, and pagination.',
472497
request: {
473-
query: z.object({
474-
page: z.coerce
475-
.number()
476-
.int()
477-
.min(1)
478-
.optional()
479-
.openapi({ description: 'Page number (requires limit).' }),
480-
limit: z.coerce
481-
.number()
482-
.int()
483-
.min(1)
484-
.max(100)
485-
.optional()
486-
.openapi({ description: 'Items per page (requires page).' }),
487-
q: z.string().optional().openapi({ description: 'Search query (title, creator, or id).' }),
488-
search: z.string().optional().openapi({ description: 'Alias for q.' }),
489-
asset: z.string().optional().openapi({ description: 'Comma-separated list of asset codes.' }),
490-
status: z.enum(['open', 'funded', 'claimed', 'failed']).optional(),
491-
sort: z.enum(['createdAt', 'deadline', 'pledgedAmount', 'targetAmount']).optional(),
492-
order: z.enum(['asc', 'desc']).optional(),
493-
includeDeleted: z.enum(['true', 'false']).optional(),
494-
createdAfter: z
495-
.string()
496-
.datetime()
497-
.optional()
498-
.openapi({ description: 'ISO 8601 timestamp.' }),
499-
createdBefore: z
500-
.string()
501-
.datetime()
502-
.optional()
503-
.openapi({ description: 'ISO 8601 timestamp.' }),
504-
}),
498+
query: campaignListQuerySchema,
505499
},
506500
responses: {
507501
200: {
@@ -512,6 +506,26 @@ registry.registerPath({
512506
},
513507
});
514508

509+
registry.registerPath({
510+
method: 'get',
511+
path: '/api/creators/{address}/campaigns',
512+
tags: ['Campaigns'],
513+
summary: "List a creator's campaigns",
514+
description:
515+
'List all campaigns created by the given Stellar address, with the same filtering, sorting, and pagination as GET /api/campaigns. Returns an empty list for addresses with no campaigns.',
516+
request: {
517+
params: z.object({ address: stellarAddressSchema }),
518+
query: campaignListQuerySchema,
519+
},
520+
responses: {
521+
200: {
522+
description: "Paginated list of the creator's campaigns",
523+
content: { 'application/json': { schema: registeredSchemas.CampaignListResponse } },
524+
},
525+
400: validationErrorResponse,
526+
},
527+
});
528+
515529
registry.registerPath({
516530
method: 'post',
517531
path: '/api/campaigns',

backend/src/services/campaignStore.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ export type SortOrder = 'asc' | 'desc';
282282

283283
export interface ListCampaignsOptions {
284284
searchQuery?: string;
285+
creator?: string;
285286
assetCode?: string;
286287
assetCodes?: string[];
287288
status?: CampaignStatus;
@@ -375,6 +376,10 @@ if (options?.searchQuery && options.searchQuery.trim()) {
375376
params.push(creatorExactTerm, exactTerm);
376377
}
377378
}
379+
if (options?.creator) {
380+
whereClauses.push(`LOWER(campaigns.creator) = LOWER(?)`);
381+
params.push(options.creator);
382+
}
378383
if (options?.assetCode) {
379384
whereClauses.push(`campaigns.accepted_tokens_json LIKE ?`);
380385
params.push(`%${options.assetCode.toUpperCase()}%`);

0 commit comments

Comments
 (0)