Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 4 additions & 15 deletions backend/src/routes/campaigns.js
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ router.get('/categories', async (req, res) => {
}
});

router.get('/', getCampaignsValidation, validateRequest, async (req, res) => {
router.get('/', getCampaignsValidation, validateRequest, asyncHandler(async (req, res) => {
/**
* @openapi
* /api/campaigns:
Expand Down Expand Up @@ -1958,31 +1958,20 @@ router.patch('/:id/visibility', requireAuth, asyncHandler(async (req, res) => {
}));

// GET /campaigns/:id/analytics — full contribution analytics
router.get('/:id/analytics', asyncHandler(async (req, res) => {
router.get('/:id/analytics', requireAuth, requireCampaignMember(), asyncHandler(async (req, res) => {
const data = await getCampaignAnalytics(req.params.id);
if (!data) return res.status(404).json({ error: 'Campaign not found' });
res.json(data);
}));

// GET /campaigns/:id/analytics/contributors — country breakdown, repeat vs first-time
router.get('/:id/analytics/contributors', requireAuth, asyncHandler(async (req, res) => {
// verify campaign exists and requester is owner or admin
const { rows } = await db.query('SELECT creator_id FROM campaigns WHERE id = $1', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Campaign not found' });
if (req.user.role !== 'admin' && rows[0].creator_id !== req.user.userId) {
return res.status(403).json({ error: 'Forbidden' });
}
router.get('/:id/analytics/contributors', requireAuth, requireCampaignMember(), asyncHandler(async (req, res) => {
const data = await getCampaignContributors(req.params.id);
res.json(data);
}));

// GET /campaigns/:id/analytics/backers — backer growth, leaderboard, repeat rate
router.get('/:id/analytics/backers', requireAuth, asyncHandler(async (req, res) => {
const { rows } = await db.query('SELECT creator_id FROM campaigns WHERE id = $1', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Campaign not found' });
if (req.user.role !== 'admin' && rows[0].creator_id !== req.user.userId) {
return res.status(403).json({ error: 'Forbidden' });
}
router.get('/:id/analytics/backers', requireAuth, requireCampaignMember(), asyncHandler(async (req, res) => {
const data = await getCampaignBackers(req.params.id);
res.json(data);
}));
Expand Down
95 changes: 94 additions & 1 deletion backend/src/routes/campaigns.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ if (!process.env.PLATFORM_SECRET_KEY) {
if (!process.env.USDC_ISSUER) {
process.env.USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5';
}
if (!process.env.DATABASE_URL) {
process.env.DATABASE_URL = 'postgres://postgres:postgres@localhost:5432/crowdpay_test';
}

function buildApp({
queryImpl,
Expand Down Expand Up @@ -104,9 +107,17 @@ function buildApp({
getCampaignsValidation: [],
validateRequest: (_req, _res, next) => next(),
},
'../services/analyticsService': {
getCampaignAnalytics: async () => ({ overview: {}, chart: [] }),
getCampaignContributors: async () => ([]),
getCampaignBackers: async () => ([]),
},
'../utils/asyncHandler': (fn) => (req, res, next) => fn(req, res, next).catch(next),
'../middleware/auth': {
requireAuth: (req, _res, next) => {
requireAuth: (req, res, next) => {
if (authUser === null) {
return res.status(401).json({ error: 'Unauthorized' });
}
req.user = authUser || { userId: 'platform-1', role: 'admin' };
next();
},
Expand Down Expand Up @@ -509,3 +520,85 @@ test('GET /api/campaigns sort=relevance without search falls back to newest', as
assert.match(listQuery.text, /ORDER BY c\.created_at DESC/);
assert.doesNotMatch(listQuery.text, /ts_rank/);
});

test('Analytics routes enforce requireAuth and requireCampaignMember', async () => {
// 1. Unauthenticated request returns 401
const unauthApp = buildApp({ authUser: null });
const res1 = await request(unauthApp).get('/api/campaigns/c-123/analytics');
assert.equal(res1.status, 401);

const res1Contrib = await request(unauthApp).get('/api/campaigns/c-123/analytics/contributors');
assert.equal(res1Contrib.status, 401);

const res1Backers = await request(unauthApp).get('/api/campaigns/c-123/analytics/backers');
assert.equal(res1Backers.status, 401);

// 2. Non-member non-owner user returns 403
const nonMemberApp = buildApp({
authUser: { userId: 'stranger-1', role: 'user' },
queryImpl: async (text) => {
if (text.includes('FROM campaigns')) {
return { rows: [{ creator_id: 'creator-1' }] };
}
if (text.includes('FROM campaign_members')) {
return { rows: [] };
}
return { rows: [] };
},
});

const res2 = await request(nonMemberApp).get('/api/campaigns/c-123/analytics');
assert.equal(res2.status, 403);

const res2Contrib = await request(nonMemberApp).get('/api/campaigns/c-123/analytics/contributors');
assert.equal(res2Contrib.status, 403);

const res2Backers = await request(nonMemberApp).get('/api/campaigns/c-123/analytics/backers');
assert.equal(res2Backers.status, 403);

// 3. Campaign creator returns 200
const creatorApp = buildApp({
authUser: { userId: 'creator-1', role: 'user' },
queryImpl: async (text) => {
if (text.includes('FROM campaigns')) {
return { rows: [{ creator_id: 'creator-1' }] };
}
if (text.includes('FROM campaign_members')) {
return { rows: [] };
}
return { rows: [] };
},
});

const res3 = await request(creatorApp).get('/api/campaigns/c-123/analytics');
assert.equal(res3.status, 200);

const res3Contrib = await request(creatorApp).get('/api/campaigns/c-123/analytics/contributors');
assert.equal(res3Contrib.status, 200);

const res3Backers = await request(creatorApp).get('/api/campaigns/c-123/analytics/backers');
assert.equal(res3Backers.status, 200);

// 4. Accepted campaign member returns 200
const memberApp = buildApp({
authUser: { userId: 'member-1', role: 'user' },
queryImpl: async (text) => {
if (text.includes('FROM campaigns')) {
return { rows: [{ creator_id: 'creator-1' }] };
}
if (text.includes('FROM campaign_members')) {
return { rows: [{ role: 'viewer', accepted_at: '2026-01-01' }] };
}
return { rows: [] };
},
});

const res4 = await request(memberApp).get('/api/campaigns/c-123/analytics');
assert.equal(res4.status, 200);

const res4Contrib = await request(memberApp).get('/api/campaigns/c-123/analytics/contributors');
assert.equal(res4Contrib.status, 200);

const res4Backers = await request(memberApp).get('/api/campaigns/c-123/analytics/backers');
assert.equal(res4Backers.status, 200);
});