Skip to content

Commit 9bd1903

Browse files
authored
Merge branch 'main' into test/get-contribution-coverage
2 parents 5b9ca0c + e984190 commit 9bd1903

18 files changed

Lines changed: 3476 additions & 326 deletions

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ stellar contract invoke --id $CONTRACT_ID -- update_metadata \
9999

100100
The contract emits a `MetadataUpdated` event containing both the old and new metadata values. The backend event indexer processes this event and updates local state automatically.
101101

102+
### Multi-token campaigns (issue #191)
103+
104+
Campaigns can accept more than one Stellar asset code. When `acceptedTokens` contains multiple entries the frontend renders a per-token progress bar beneath the main progress bar, and the pledge form shows a token selector so contributors can choose which asset to pledge.
105+
106+
The backend tracks per-token pledge totals in the `tokenBalances` field (a `Record<assetCode, amount>` map built from the `pledges` table grouped by `asset_code`). This is returned on every `GET /api/campaigns/:id` response and on the campaign list.
107+
108+
**Contract side:** The Soroban contract stores `accepted_tokens: Vec<String>` on each campaign. `contribute()` validates that the pledged asset is in the list before recording the pledge.
109+
110+
**Frontend side:** `CampaignCard` renders individual `<div class="progress-bar">` elements for each accepted token when `tokenBalances` is present. `CampaignDetailPanel` conditionally shows a `<select>` token picker above the amount field when `acceptedTokens.length > 1`.
111+
112+
**Backend side:** `getCampaignTokenBalances(campaignId)` queries the `pledges` table grouped by `asset_code` and returns the map. `getCampaign()` populates `campaign.tokenBalances` on every read.
113+
102114
### Deadline extension governance (issue #192)
103115

104116
Any existing contributor can request a deadline extension:
@@ -203,6 +215,26 @@ Base URL:
203215
- `status` is `ok` when the API and database probe succeed, otherwise `degraded`
204216
- `database.status` is `up` or `down` based on a lightweight SQLite reachability check
205217

218+
### `GET /api/stats`
219+
220+
- Returns aggregate metrics and totals computed from campaigns and pledges.
221+
- Cached with a 30-second TTL.
222+
- Response:
223+
224+
```json
225+
{
226+
"data": {
227+
"totalCampaigns": 10,
228+
"openCampaigns": 5,
229+
"fundedCampaigns": 3,
230+
"claimedCampaigns": 1,
231+
"failedCampaigns": 1,
232+
"totalPledgeVolume": 50000,
233+
"uniqueContributors": 42
234+
}
235+
}
236+
```
237+
206238
### `GET /api/campaigns`
207239

208240
- Returns all campaigns with computed progress

backend/package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"cors": "^2.8.5",
1111
"dotenv": "^17.3.1",
1212
"express": "^4.21.2",
13+
"lru-cache": "^11.5.1",
1314
"redis": "^4.6.13",
1415
"zod": "^4.3.6"
1516
},

backend/src/api.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,3 +420,19 @@ describe('Campaign maxPerContributor Field', () => {
420420
expect(detailRes.data.data.maxPerContributor).toBe(75);
421421
});
422422
});
423+
424+
describe('GET /api/stats', () => {
425+
it('returns aggregate metrics in the correct format', async () => {
426+
const res = await get('/api/stats');
427+
expect(res.status).toBe(200);
428+
expect(res.data.data).toMatchObject({
429+
totalCampaigns: expect.any(Number),
430+
openCampaigns: expect.any(Number),
431+
fundedCampaigns: expect.any(Number),
432+
claimedCampaigns: expect.any(Number),
433+
failedCampaigns: expect.any(Number),
434+
totalPledgeVolume: expect.any(Number),
435+
uniqueContributors: expect.any(Number),
436+
});
437+
});
438+
});

backend/src/index.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ import {
5858
normalizeQueryValue,
5959
} from './validation/schemas';
6060
import { logError, logInfo } from './logger';
61+
import {
62+
buildCampaignCacheKey,
63+
getCampaignCacheEntry,
64+
invalidateCampaignCache,
65+
setCampaignCacheEntry,
66+
} from './services/campaignCache';
6167
export const app = express();
6268

6369
type CampaignListItem = CampaignRecord & { progress: CampaignProgress };
@@ -270,6 +276,22 @@ app.get('/api/campaigns', (req: Request, res: Response) => {
270276

271277
const params = queryResult.data;
272278

279+
// Build a stable cache key from the sorted query string
280+
const qs = Object.keys(req.query as Record<string, unknown>)
281+
.sort()
282+
.map((k) => `${k}=${(req.query as Record<string, unknown>)[k]}`)
283+
.join('&');
284+
const cacheKey = buildCampaignCacheKey(qs);
285+
286+
const cached = getCampaignCacheEntry(cacheKey);
287+
if (cached) {
288+
res.setHeader('Cache-Control', 'max-age=5');
289+
res.setHeader('X-Cache', 'HIT');
290+
res.setHeader('Content-Type', 'application/json');
291+
res.send(cached);
292+
return;
293+
}
294+
273295
const listOptions: ListCampaignsOptions = {
274296
searchQuery: params.search || params.q,
275297
assetCodes: params.asset,
@@ -299,7 +321,7 @@ app.get('/api/campaigns', (req: Request, res: Response) => {
299321
? 1
300322
: Math.max(1, Math.ceil(totalCount / limit));
301323

302-
res.json({
324+
const responseBody = JSON.stringify({
303325
data,
304326
pagination: {
305327
total: totalCount,
@@ -308,6 +330,13 @@ app.get('/api/campaigns', (req: Request, res: Response) => {
308330
totalPages,
309331
},
310332
});
333+
334+
setCampaignCacheEntry(cacheKey, responseBody);
335+
336+
res.setHeader('Cache-Control', 'max-age=5');
337+
res.setHeader('X-Cache', 'MISS');
338+
res.setHeader('Content-Type', 'application/json');
339+
res.send(responseBody);
311340
});
312341

313342
app.get('/api/campaigns/:id', (req: Request, res: Response) => {
@@ -382,6 +411,7 @@ app.post('/api/campaigns', (req: Request, res: Response) => {
382411
};
383412

384413
const campaign = createCampaign(campaignInput);
414+
invalidateCampaignCache();
385415
res.status(201).json({ data: { ...campaign, progress: calculateProgress(campaign) } });
386416
});
387417

@@ -400,6 +430,7 @@ app.post(
400430
}
401431

402432
const campaign = addPledge(parsedId.value, parsedBody.data);
433+
invalidateCampaignCache();
403434
res.status(201).json({ data: { ...campaign, progress: calculateProgress(campaign) } });
404435
},
405436
);
@@ -419,6 +450,7 @@ app.post(
419450
}
420451

421452
const campaign = reconcileOnChainPledge(parsedId.value, parsedBody.data);
453+
invalidateCampaignCache();
422454
res.status(201).json({
423455
data: {
424456
campaign: { ...campaign, progress: calculateProgress(campaign) },
@@ -447,6 +479,7 @@ app.post(
447479
transactionHash: parsedBody.data.transactionHash,
448480
confirmedAt: parsedBody.data.confirmedAt,
449481
});
482+
invalidateCampaignCache();
450483
res.json({ data: { ...campaign, progress: calculateProgress(campaign) } });
451484
},
452485
);
@@ -476,6 +509,7 @@ app.post(
476509
latestLedger: verified.latestLedger ?? parsedBody.data.soroban.latestLedger,
477510
source: 'soroban-contract',
478511
});
512+
invalidateCampaignCache();
479513

480514
res.json({
481515
data: {
@@ -554,9 +588,19 @@ app.get('/api/config', (_req: Request, res: Response) => {
554588
});
555589
});
556590

557-
app.get('/api/stats', (_req: Request, res: Response) => {
591+
app.get('/api/stats', cacheMiddleware(30), (_req: Request, res: Response) => {
558592
const stats = getGlobalStats();
559-
res.json({ data: stats });
593+
res.json({
594+
data: {
595+
totalCampaigns: stats.totalCampaigns,
596+
openCampaigns: stats.campaignCountByStatus.open,
597+
fundedCampaigns: stats.campaignCountByStatus.funded,
598+
claimedCampaigns: stats.campaignCountByStatus.claimed,
599+
failedCampaigns: stats.campaignCountByStatus.failed,
600+
totalPledgeVolume: stats.totalPledgedAmount,
601+
uniqueContributors: stats.totalContributors,
602+
}
603+
});
560604
});
561605

562606
app.get('/api/leaderboard', (req: Request, res: Response) => {

backend/src/middleware/requestLogging.ts

Lines changed: 0 additions & 22 deletions
This file was deleted.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import {
3+
buildCampaignCacheKey,
4+
getCampaignCacheEntry,
5+
setCampaignCacheEntry,
6+
invalidateCampaignCache,
7+
getCampaignCacheSize,
8+
} from './campaignCache';
9+
10+
describe('campaignCache', () => {
11+
beforeEach(() => {
12+
invalidateCampaignCache();
13+
});
14+
15+
it('returns undefined for a key that has not been set', () => {
16+
expect(getCampaignCacheEntry('campaigns:missing')).toBeUndefined();
17+
});
18+
19+
it('returns the stored body after a set', () => {
20+
const key = buildCampaignCacheKey('status=open');
21+
const body = JSON.stringify({ data: [], pagination: {} });
22+
setCampaignCacheEntry(key, body);
23+
expect(getCampaignCacheEntry(key)).toBe(body);
24+
});
25+
26+
it('buildCampaignCacheKey namespaces the key correctly', () => {
27+
expect(buildCampaignCacheKey('foo=bar')).toBe('campaigns:foo=bar');
28+
expect(buildCampaignCacheKey('')).toBe('campaigns:');
29+
});
30+
31+
it('stores separate entries for different query strings', () => {
32+
const k1 = buildCampaignCacheKey('status=open');
33+
const k2 = buildCampaignCacheKey('status=funded');
34+
setCampaignCacheEntry(k1, 'open-response');
35+
setCampaignCacheEntry(k2, 'funded-response');
36+
expect(getCampaignCacheEntry(k1)).toBe('open-response');
37+
expect(getCampaignCacheEntry(k2)).toBe('funded-response');
38+
});
39+
40+
it('invalidateCampaignCache clears all entries', () => {
41+
setCampaignCacheEntry(buildCampaignCacheKey('a=1'), 'body-a');
42+
setCampaignCacheEntry(buildCampaignCacheKey('b=2'), 'body-b');
43+
expect(getCampaignCacheSize()).toBe(2);
44+
45+
invalidateCampaignCache();
46+
47+
expect(getCampaignCacheSize()).toBe(0);
48+
expect(getCampaignCacheEntry(buildCampaignCacheKey('a=1'))).toBeUndefined();
49+
});
50+
51+
it('overwrites an existing entry for the same key', () => {
52+
const key = buildCampaignCacheKey('page=1');
53+
setCampaignCacheEntry(key, 'first');
54+
setCampaignCacheEntry(key, 'second');
55+
expect(getCampaignCacheEntry(key)).toBe('second');
56+
});
57+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { LRUCache } from 'lru-cache';
2+
3+
const CACHE_TTL_MS = 5_000;
4+
const CACHE_MAX_SIZE = Number(process.env.CAMPAIGN_CACHE_MAX_SIZE ?? 100);
5+
6+
interface CacheEntry {
7+
body: string;
8+
}
9+
10+
const cache = new LRUCache<string, CacheEntry>({
11+
max: CACHE_MAX_SIZE,
12+
ttl: CACHE_TTL_MS,
13+
});
14+
15+
export function buildCampaignCacheKey(queryString: string): string {
16+
return `campaigns:${queryString}`;
17+
}
18+
19+
export function getCampaignCacheEntry(key: string): string | undefined {
20+
return cache.get(key)?.body;
21+
}
22+
23+
export function setCampaignCacheEntry(key: string, body: string): void {
24+
cache.set(key, { body });
25+
}
26+
27+
export function invalidateCampaignCache(): void {
28+
cache.clear();
29+
}
30+
31+
export function getCampaignCacheSize(): number {
32+
return cache.size;
33+
}

backend/src/services/db.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export function initDb(): void {
3838
// This is the chosen journal mode to prevent unnecessary lock contention,
3939
// allowing reads and writes to occur concurrently without blocking each other.
4040
db.pragma('journal_mode = WAL');
41+
db.pragma('synchronous = NORMAL');
4142
db.pragma('foreign_keys = ON');
4243

4344
migrate(db);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import path from 'path';
2+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3+
4+
const TEST_DB = path.join('/tmp', `sgv-wal-test-${process.pid}.db`);
5+
6+
describe('SQLite WAL configuration (#218)', () => {
7+
beforeEach(() => {
8+
process.env.DB_PATH = TEST_DB;
9+
});
10+
11+
afterEach(async () => {
12+
const { resetDbForTests } = await import('./db');
13+
resetDbForTests();
14+
const fs = await import('fs');
15+
fs.rmSync(TEST_DB, { force: true });
16+
fs.rmSync(`${TEST_DB}-wal`, { force: true });
17+
fs.rmSync(`${TEST_DB}-shm`, { force: true });
18+
});
19+
20+
it('enables WAL journal mode on init', async () => {
21+
const { initDb, getDb } = await import('./db');
22+
initDb();
23+
const row = getDb().pragma('journal_mode', { simple: true });
24+
expect(row).toBe('wal');
25+
});
26+
27+
it('sets synchronous=NORMAL on init', async () => {
28+
const { initDb, getDb } = await import('./db');
29+
initDb();
30+
const row = getDb().pragma('synchronous', { simple: true });
31+
// 1 = NORMAL in SQLite pragma integer encoding
32+
expect(row).toBe(1);
33+
});
34+
35+
it('enables foreign key enforcement on init', async () => {
36+
const { initDb, getDb } = await import('./db');
37+
initDb();
38+
const row = getDb().pragma('foreign_keys', { simple: true });
39+
expect(row).toBe(1);
40+
});
41+
});

0 commit comments

Comments
 (0)