Skip to content

Commit 99ecdaf

Browse files
authored
Merge pull request #737 from Fayedamz/feature/580-trending-campaigns-endpoint
feat: add GET /api/campaigns/trending endpoint (#580)
2 parents ad07c40 + 22b386e commit 99ecdaf

3 files changed

Lines changed: 107 additions & 0 deletions

File tree

backend/src/index.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
getCampaignWithProgress,
3232
getContributorSummary,
3333
getGlobalStats,
34+
getTrendingCampaigns,
3435
getTopContributors,
3536
initCampaignStore,
3637
listCampaignPledges,
@@ -68,6 +69,8 @@ import { logError, logInfo } from './logger';
6869
import {
6970
buildCampaignCacheKey,
7071
getCampaignCacheEntry,
72+
getTrendingCacheEntry,
73+
setTrendingCacheEntry,
7174
invalidateCampaignCache,
7275
setCampaignCacheEntry,
7376
} from './services/campaignCache';
@@ -463,6 +466,28 @@ app.get('/api/campaigns', (req: Request, res: Response) => {
463466
res.send(responseBody);
464467
});
465468

469+
app.get('/api/campaigns/trending', (req: Request, res: Response) => {
470+
const cached = getTrendingCacheEntry();
471+
if (cached) {
472+
res.setHeader('X-Cache', 'HIT');
473+
res.setHeader('Content-Type', 'application/json');
474+
res.send(cached);
475+
return;
476+
}
477+
478+
const campaigns = getTrendingCampaigns(10);
479+
480+
const responseBody = JSON.stringify({
481+
data: campaigns,
482+
});
483+
484+
setTrendingCacheEntry(responseBody);
485+
486+
res.setHeader('X-Cache', 'MISS');
487+
res.setHeader('Content-Type', 'application/json');
488+
res.send(responseBody);
489+
});
490+
466491
app.get('/api/campaigns/:id', (req: Request, res: Response) => {
467492
const parsedId = parseCampaignId(req.params.id);
468493
if (!parsedId.ok) {

backend/src/services/campaignCache.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,27 @@ import { LRUCache } from 'lru-cache';
33
const CACHE_TTL_MS = 5_000;
44
const CACHE_MAX_SIZE = Number(process.env.CAMPAIGN_CACHE_MAX_SIZE ?? 100);
55

6+
// Trending endpoint cache: 10 minutes TTL as per spec
7+
const TRENDING_CACHE_TTL_MS = 10 * 60 * 1000;
8+
const TRENDING_CACHE_KEY = 'trending:campaigns';
9+
10+
const trendingCache = new LRUCache<string, string>({
11+
max: 1,
12+
ttl: TRENDING_CACHE_TTL_MS,
13+
});
14+
15+
export function getTrendingCacheEntry(): string | undefined {
16+
return trendingCache.get(TRENDING_CACHE_KEY);
17+
}
18+
19+
export function setTrendingCacheEntry(body: string): void {
20+
trendingCache.set(TRENDING_CACHE_KEY, body);
21+
}
22+
23+
export function invalidateTrendingCache(): void {
24+
trendingCache.clear();
25+
}
26+
627
interface CacheEntry {
728
body: string;
829
}

backend/src/services/campaignStore.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,6 +1317,67 @@ export function updateCampaignMetadata(campaignId: string, newMetadata: string):
13171317
);
13181318
}
13191319

1320+
export interface TrendingCampaignEntry {
1321+
campaign: CampaignRecord;
1322+
progress: CampaignProgress;
1323+
pledgeVelocity: number; // pledges in last 24h / hours the campaign has been open
1324+
recentPledgeCount: number; // raw count of non-refunded pledges in last 24h
1325+
}
1326+
1327+
/**
1328+
* Returns the top 10 open campaigns ranked by pledge velocity.
1329+
*
1330+
* Velocity = (non-refunded pledges in last 24h) / hours the campaign has been open.
1331+
* Only campaigns that are currently open (not funded, claimed, or failed) are included.
1332+
*
1333+
* @param limit - Maximum number of trending campaigns to return (default: 10).
1334+
* @returns An array of {@link TrendingCampaignEntry} objects sorted by velocity descending.
1335+
*/
1336+
export function getTrendingCampaigns(limit = 10): TrendingCampaignEntry[] {
1337+
const db = getDb();
1338+
const now = nowInSeconds();
1339+
const window24h = now - 86400; // 24 hours ago in unix seconds
1340+
1341+
// Fetch open campaigns with their 24h pledge counts in one query.
1342+
// "Open" = deadline in the future, not claimed, not yet at target (funded).
1343+
// deleted_at IS NULL is always enforced.
1344+
const rows = db
1345+
.prepare(
1346+
`SELECT
1347+
c.*,
1348+
COUNT(p.id) AS recent_pledge_count
1349+
FROM campaigns c
1350+
LEFT JOIN pledges p
1351+
ON p.campaign_id = c.id
1352+
AND p.refunded_at IS NULL
1353+
AND p.created_at >= ?
1354+
WHERE c.deleted_at IS NULL
1355+
AND c.claimed_at IS NULL
1356+
AND c.pledged_amount < c.target_amount
1357+
AND c.deadline > ?
1358+
GROUP BY c.id
1359+
ORDER BY recent_pledge_count DESC, c.pledged_amount DESC
1360+
LIMIT ?`,
1361+
)
1362+
.all(window24h, now, limit) as Array<CampaignRow & { recent_pledge_count: number }>;
1363+
1364+
return rows.map((row) => {
1365+
const { recent_pledge_count, ...campaignRow } = row;
1366+
const campaign = rowToCampaign(campaignRow as CampaignRow);
1367+
1368+
// hoursOpen: at least 1 hour floor to avoid division-by-zero on brand-new campaigns
1369+
const hoursOpen = Math.max(1, (now - campaign.createdAt) / 3600);
1370+
const pledgeVelocity = Number((recent_pledge_count / hoursOpen).toFixed(4));
1371+
1372+
return {
1373+
campaign,
1374+
progress: calculateProgress(campaign, now),
1375+
pledgeVelocity,
1376+
recentPledgeCount: recent_pledge_count,
1377+
};
1378+
});
1379+
}
1380+
13201381
export function getTopContributors(limit: number = 10): LeaderboardEntry[] {
13211382
const db = getDb();
13221383
const rows = db

0 commit comments

Comments
 (0)