Skip to content

Commit 22b386e

Browse files
committed
feat: add GET /api/campaigns/trending endpoint (#580)
- Add getTrendingCampaigns() to campaignStore: queries open campaigns only (not funded/claimed/failed), counts non-refunded pledges in last 24h per campaign, computes velocity = recent_pledges / hours_open, returns top 10 sorted by velocity desc - Add trending LRU cache (10-min TTL) to campaignCache.ts with getTrendingCacheEntry / setTrendingCacheEntry / invalidateTrendingCache - Register GET /api/campaigns/trending route in index.ts with X-Cache HIT/MISS header; route placed before /:id to avoid param capture
1 parent 10f827c commit 22b386e

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
@@ -30,6 +30,7 @@ import {
3030
getCampaignWithProgress,
3131
getContributorSummary,
3232
getGlobalStats,
33+
getTrendingCampaigns,
3334
getTopContributors,
3435
initCampaignStore,
3536
listCampaignPledges,
@@ -64,6 +65,8 @@ import { logError, logInfo } from './logger';
6465
import {
6566
buildCampaignCacheKey,
6667
getCampaignCacheEntry,
68+
getTrendingCacheEntry,
69+
setTrendingCacheEntry,
6770
invalidateCampaignCache,
6871
setCampaignCacheEntry,
6972
} from './services/campaignCache';
@@ -428,6 +431,28 @@ app.get('/api/campaigns', (req: Request, res: Response) => {
428431
res.send(responseBody);
429432
});
430433

434+
app.get('/api/campaigns/trending', (req: Request, res: Response) => {
435+
const cached = getTrendingCacheEntry();
436+
if (cached) {
437+
res.setHeader('X-Cache', 'HIT');
438+
res.setHeader('Content-Type', 'application/json');
439+
res.send(cached);
440+
return;
441+
}
442+
443+
const campaigns = getTrendingCampaigns(10);
444+
445+
const responseBody = JSON.stringify({
446+
data: campaigns,
447+
});
448+
449+
setTrendingCacheEntry(responseBody);
450+
451+
res.setHeader('X-Cache', 'MISS');
452+
res.setHeader('Content-Type', 'application/json');
453+
res.send(responseBody);
454+
});
455+
431456
app.get('/api/campaigns/:id', (req: Request, res: Response) => {
432457
const parsedId = parseCampaignId(req.params.id);
433458
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
@@ -1232,6 +1232,67 @@ export function updateCampaignMetadata(campaignId: string, newMetadata: string):
12321232
);
12331233
}
12341234

1235+
export interface TrendingCampaignEntry {
1236+
campaign: CampaignRecord;
1237+
progress: CampaignProgress;
1238+
pledgeVelocity: number; // pledges in last 24h / hours the campaign has been open
1239+
recentPledgeCount: number; // raw count of non-refunded pledges in last 24h
1240+
}
1241+
1242+
/**
1243+
* Returns the top 10 open campaigns ranked by pledge velocity.
1244+
*
1245+
* Velocity = (non-refunded pledges in last 24h) / hours the campaign has been open.
1246+
* Only campaigns that are currently open (not funded, claimed, or failed) are included.
1247+
*
1248+
* @param limit - Maximum number of trending campaigns to return (default: 10).
1249+
* @returns An array of {@link TrendingCampaignEntry} objects sorted by velocity descending.
1250+
*/
1251+
export function getTrendingCampaigns(limit = 10): TrendingCampaignEntry[] {
1252+
const db = getDb();
1253+
const now = nowInSeconds();
1254+
const window24h = now - 86400; // 24 hours ago in unix seconds
1255+
1256+
// Fetch open campaigns with their 24h pledge counts in one query.
1257+
// "Open" = deadline in the future, not claimed, not yet at target (funded).
1258+
// deleted_at IS NULL is always enforced.
1259+
const rows = db
1260+
.prepare(
1261+
`SELECT
1262+
c.*,
1263+
COUNT(p.id) AS recent_pledge_count
1264+
FROM campaigns c
1265+
LEFT JOIN pledges p
1266+
ON p.campaign_id = c.id
1267+
AND p.refunded_at IS NULL
1268+
AND p.created_at >= ?
1269+
WHERE c.deleted_at IS NULL
1270+
AND c.claimed_at IS NULL
1271+
AND c.pledged_amount < c.target_amount
1272+
AND c.deadline > ?
1273+
GROUP BY c.id
1274+
ORDER BY recent_pledge_count DESC, c.pledged_amount DESC
1275+
LIMIT ?`,
1276+
)
1277+
.all(window24h, now, limit) as Array<CampaignRow & { recent_pledge_count: number }>;
1278+
1279+
return rows.map((row) => {
1280+
const { recent_pledge_count, ...campaignRow } = row;
1281+
const campaign = rowToCampaign(campaignRow as CampaignRow);
1282+
1283+
// hoursOpen: at least 1 hour floor to avoid division-by-zero on brand-new campaigns
1284+
const hoursOpen = Math.max(1, (now - campaign.createdAt) / 3600);
1285+
const pledgeVelocity = Number((recent_pledge_count / hoursOpen).toFixed(4));
1286+
1287+
return {
1288+
campaign,
1289+
progress: calculateProgress(campaign, now),
1290+
pledgeVelocity,
1291+
recentPledgeCount: recent_pledge_count,
1292+
};
1293+
});
1294+
}
1295+
12351296
export function getTopContributors(limit: number = 10): LeaderboardEntry[] {
12361297
const db = getDb();
12371298
const rows = db

0 commit comments

Comments
 (0)