diff --git a/backend/src/routes/campaigns.js b/backend/src/routes/campaigns.js index 6d7b10e..f2c7454 100644 --- a/backend/src/routes/campaigns.js +++ b/backend/src/routes/campaigns.js @@ -25,6 +25,7 @@ const { getCampaignsValidation, validateRequest, } = require('../middleware/validation'); +const asyncHandler = require('../utils/asyncHandler'); const crypto = require('crypto'); @@ -40,7 +41,7 @@ function stripHtml(value = '') { */ const requireCampaignMember = (...allowedRoles) => { - return async (req, res, next) => { + return asyncHandler(async (req, res, next) => { const campaignId = req.params.id || req.params.campaign_id || req.body.campaign_id; if (!campaignId) return res.status(400).json({ error: 'Campaign ID is required' }); @@ -80,7 +81,7 @@ const requireCampaignMember = (...allowedRoles) => { req.campaignRole = role; next(); - }; + }); }; const upload = multer({ @@ -152,7 +153,7 @@ async function logWithdrawalEvent(client, { withdrawalRequestId, actorUserId, ac } // List campaigns with optional search, filtering, sorting, and pagination -router.get('/', getCampaignsValidation, validateRequest, async (req, res) => { +router.get('/', getCampaignsValidation, validateRequest, asyncHandler(async (req, res) => { /** * @openapi * /api/campaigns: @@ -251,14 +252,14 @@ router.get('/', getCampaignsValidation, validateRequest, async (req, res) => { const result = await db.query(query, [...params, limit, offset]); res.json({ total, limit, offset, campaigns: result.rows }); -}); +})); -router.get('/mine', requireAuth, async (req, res) => { +router.get('/mine', requireAuth, asyncHandler(async (req, res) => { const campaigns = await listCreatorCampaigns(req.user.userId); res.json(campaigns); -}); +})); -router.get('/:id/milestones', async (req, res) => { +router.get('/:id/milestones', asyncHandler(async (req, res) => { const { rows } = await db.query( `SELECT m.*, (c.milestones_contract_id IS NOT NULL) AS on_chain FROM milestones m @@ -268,9 +269,9 @@ router.get('/:id/milestones', async (req, res) => { [req.params.id] ); res.json(rows); -}); +})); -router.post('/:id/milestones', requireAuth, requireCampaignMember('owner'), async (req, res) => { +router.post('/:id/milestones', requireAuth, requireCampaignMember('owner'), asyncHandler(async (req, res) => { let normalizedMilestones; try { normalizedMilestones = normalizeMilestonesInput(req.body?.milestones); @@ -339,10 +340,10 @@ router.post('/:id/milestones', requireAuth, requireCampaignMember('owner'), asyn } finally { client.release(); } -}); +})); // Get single Campaign -router.get('/:id', async (req, res) => { +router.get('/:id', asyncHandler(async (req, res) => { /** * @openapi * /api/campaigns/{id}: @@ -414,10 +415,10 @@ router.get('/:id', async (req, res) => { } res.json(response); -}); +})); // Embeddable campaign widget data (public, with permissive CORS) -router.get('/:id/embed', async (req, res) => { +router.get('/:id/embed', asyncHandler(async (req, res) => { // Allow this endpoint to be accessed from any origin for embedding res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET'); @@ -449,10 +450,10 @@ router.get('/:id/embed', async (req, res) => { progress_percentage: Math.round(pct * 10) / 10, contribution_url: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/campaigns/${campaign.id}`, }); -}); +})); // Get backers for a campaign -router.get('/:id/backers', async (req, res) => { +router.get('/:id/backers', asyncHandler(async (req, res) => { const campaignId = req.params.id; const { rows: campaignRows } = await db.query('SELECT show_backer_amounts FROM campaigns WHERE id = $1', [campaignId]); if (!campaignRows.length) return res.status(404).json({ error: 'Campaign not found' }); @@ -471,10 +472,10 @@ router.get('/:id/backers', async (req, res) => { `; const { rows } = await db.query(query, [campaignId]); res.json(rows); -}); +})); // SSE stream for real-time campaign funding updates -router.get('/:id/stream', async (req, res) => { +router.get('/:id/stream', asyncHandler(async (req, res) => { const campaignId = parseInt(req.params.id, 10); const { rows } = await db.query('SELECT id FROM campaigns WHERE id = $1', [campaignId]); if (!rows.length) return res.status(404).json({ error: 'Campaign not found' }); @@ -501,10 +502,10 @@ router.get('/:id/stream', async (req, res) => { clearInterval(heartbeat); removeSSEClient(campaignId, res); }); -}); +})); // Get live on-chain balance for a campaign -router.get('/:id/balance', async (req, res) => { +router.get('/:id/balance', asyncHandler(async (req, res) => { /** * @openapi * /api/campaigns/{id}/balance: @@ -538,16 +539,16 @@ router.get('/:id/balance', async (req, res) => { if (!rows.length) return res.status(404).json({ error: 'Campaign not found' }); const balance = await getCampaignBalance(rows[0].wallet_public_key); res.json(balance); -}); +})); // Scheduled endpoint to fail expired campaigns and prevent further contributions -router.post('/cron/fail-expired', requireAuth, requireRole('admin'), async (req, res) => { +router.post('/cron/fail-expired', requireAuth, requireRole('admin'), asyncHandler(async (req, res) => { const { failed, funded } = await refreshActiveCampaignStatuses(); res.json({ failedCampaigns: failed, fundedCampaigns: funded }); -}); +})); // Scheduled endpoint to send 48h deadline reminders -router.post('/cron/reminders', requireAuth, requireRole('admin'), async (req, res) => { +router.post('/cron/reminders', requireAuth, requireRole('admin'), asyncHandler(async (req, res) => { // Find campaigns ending in exactly 2 days that are still active const { rows } = await db.query( `SELECT c.id, c.title, c.deadline, u.email as creator_email @@ -567,10 +568,10 @@ If your target is reached, you can request a withdrawal. Otherwise, contribution } res.json({ remindersSent: rows.length }); -}); +})); // Trigger refund withdrawal requests for a failed campaign -router.post('/:id/trigger-refunds', requireAuth, requireRole('admin'), async (req, res) => { +router.post('/:id/trigger-refunds', requireAuth, requireRole('admin'), asyncHandler(async (req, res) => { const campaignId = req.params.id; const { rows: campaigns } = await db.query( `SELECT id, wallet_public_key, status FROM campaigns WHERE id = $1`, @@ -647,10 +648,10 @@ router.post('/:id/trigger-refunds', requireAuth, requireRole('admin'), async (re } finally { client.release(); } -}); +})); // Create campaign (authenticated) -router.post('/', requireAuth, requireRole('creator', 'admin'), createCampaignValidation, validateRequest, async (req, res) => { +router.post('/', requireAuth, requireRole('creator', 'admin'), createCampaignValidation, validateRequest, asyncHandler(async (req, res) => { /** * @openapi * /api/campaigns: @@ -788,10 +789,10 @@ router.post('/', requireAuth, requireRole('creator', 'admin'), createCampaignVal watchCampaignWallet(campaign.id, wallet.publicKey); res.status(201).json(campaign); -}); +})); // PATCH /campaigns/:id - Update campaign (title, description, deadline) -router.patch('/:id', requireAuth, async (req, res) => { +router.patch('/:id', requireAuth, asyncHandler(async (req, res) => { const campaignId = req.params.id; const { title, description, deadline } = req.body; @@ -905,7 +906,7 @@ router.patch('/:id', requireAuth, async (req, res) => { } res.json(updatedRows[0]); -}); +})); router.post( '/:id/cover-image', @@ -949,7 +950,7 @@ router.post( } ); -router.get('/:id/updates', async (req, res) => { +router.get('/:id/updates', asyncHandler(async (req, res) => { const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 10)); const offset = Math.max(0, Number(req.query.offset) || 0); const { rows } = await db.query( @@ -962,9 +963,9 @@ router.get('/:id/updates', async (req, res) => { [req.params.id, limit, offset] ); res.json(rows); -}); +})); -router.post('/:id/updates', requireAuth, requireCampaignMember('owner', 'manager'), createCampaignUpdateValidation, validateRequest, async (req, res) => { +router.post('/:id/updates', requireAuth, requireCampaignMember('owner', 'manager'), createCampaignUpdateValidation, validateRequest, asyncHandler(async (req, res) => { const { title, body } = req.body; const { rows } = await db.query( @@ -974,10 +975,10 @@ router.post('/:id/updates', requireAuth, requireCampaignMember('owner', 'manager [req.params.id, req.user.userId, title.trim(), body.trim()] ); res.status(201).json(rows[0]); -}); +})); // POST /campaigns/:id/members — owner invites a user by email -router.post('/:id/members', requireAuth, requireCampaignMember('owner'), async (req, res) => { +router.post('/:id/members', requireAuth, requireCampaignMember('owner'), asyncHandler(async (req, res) => { const { email, role } = req.body; if (!email || !role) return res.status(422).json({ error: 'Email and role are required' }); if (!['owner', 'manager', 'viewer'].includes(role)) { @@ -1023,10 +1024,10 @@ router.post('/:id/members', requireAuth, requireCampaignMember('owner'), async ( } res.status(201).json(memberRows[0]); -}); +})); // GET /campaigns/:id/members — list current team (owner only) -router.get('/:id/members', requireAuth, requireCampaignMember('owner'), async (req, res) => { +router.get('/:id/members', requireAuth, requireCampaignMember('owner'), asyncHandler(async (req, res) => { const { rows } = await db.query( `SELECT cm.id, cm.user_id, cm.email, cm.role, cm.accepted_at, cm.created_at, u.name AS user_name @@ -1037,10 +1038,10 @@ router.get('/:id/members', requireAuth, requireCampaignMember('owner'), async (r [req.params.id] ); res.json(rows); -}); +})); // PATCH /campaigns/:id/members/:userId — change role (owner only) -router.patch('/:id/members/:userId', requireAuth, requireCampaignMember('owner'), async (req, res) => { +router.patch('/:id/members/:userId', requireAuth, requireCampaignMember('owner'), asyncHandler(async (req, res) => { const { role } = req.body; if (!role || !['owner', 'manager', 'viewer'].includes(role)) { return res.status(422).json({ error: 'Invalid role. Must be owner, manager, or viewer' }); @@ -1059,10 +1060,10 @@ router.patch('/:id/members/:userId', requireAuth, requireCampaignMember('owner') } res.json(rows[0]); -}); +})); // DELETE /campaigns/:id/members/:userId — remove member or self-leave -router.delete('/:id/members/:userId', requireAuth, async (req, res) => { +router.delete('/:id/members/:userId', requireAuth, asyncHandler(async (req, res) => { const memberUserId = req.params.userId; const isSelf = String(memberUserId) === String(req.user.userId); @@ -1100,10 +1101,10 @@ router.delete('/:id/members/:userId', requireAuth, async (req, res) => { } res.json({ message: 'Member removed successfully' }); -}); +})); // POST /campaigns/:id/members/accept — accept invitation (token-based) -router.post('/:id/members/accept', requireAuth, async (req, res) => { +router.post('/:id/members/accept', requireAuth, asyncHandler(async (req, res) => { const { token: inviteToken } = req.body; if (!inviteToken) return res.status(422).json({ error: 'Invitation token is required' }); @@ -1129,6 +1130,6 @@ router.post('/:id/members/accept', requireAuth, async (req, res) => { ); res.json(rows[0]); -}); +})); module.exports = router; diff --git a/backend/src/routes/contributions.js b/backend/src/routes/contributions.js index 8de0708..a1056a6 100644 --- a/backend/src/routes/contributions.js +++ b/backend/src/routes/contributions.js @@ -24,6 +24,7 @@ const { submitCustodialContribution, } = require('../services/contributionService'); const { listUserContributions } = require('../services/userDashboardService'); +const asyncHandler = require('../utils/asyncHandler'); const SUPPORTED_ASSETS = getSupportedAssetCodes(); const PREPARED_CONTRIBUTION_EXPIRES_IN = '10m'; @@ -113,14 +114,14 @@ function validateSubmittedContributionXdr({ signedXdr, unsignedXdr, senderPublic } } -router.get('/mine', requireAuth, async (req, res) => { +router.get('/mine', requireAuth, asyncHandler(async (req, res) => { const rows = await listUserContributions(req.user.userId); if (rows === null) return res.status(404).json({ error: 'User not found' }); res.json(rows); -}); +})); // Get contributions for a campaign -router.get('/campaign/:campaignId', async (req, res) => { +router.get('/campaign/:campaignId', asyncHandler(async (req, res) => { const { rows } = await db.query( `SELECT c.id, c.sender_public_key, c.amount, c.asset, c.payment_type, c.anchor_id, c.anchor_transaction_id, c.anchor_asset, c.anchor_amount, @@ -140,17 +141,17 @@ router.get('/campaign/:campaignId', async (req, res) => { [req.params.campaignId] ); res.json(rows); -}); +})); // List contributions for the authenticated user (alias for /api/contributions/mine) -router.get('/', requireAuth, async (req, res) => { +router.get('/', requireAuth, asyncHandler(async (req, res) => { const rows = await listUserContributions(req.user.userId); if (rows === null) return res.status(404).json({ error: 'User not found' }); res.json(rows); -}); +})); // Trace contribution settlement by Stellar tx hash (submitted vs indexed on ledger) -router.get('/finalization/:txHash', requireAuth, async (req, res) => { +router.get('/finalization/:txHash', requireAuth, asyncHandler(async (req, res) => { const txHash = req.params.txHash; const { rows } = await db.query( `SELECT st.id, st.status, st.tx_hash, st.campaign_id, st.contribution_id, @@ -202,10 +203,10 @@ router.get('/finalization/:txHash', requireAuth, async (req, res) => { metadata: row.metadata, updated_at: row.updated_at, }); -}); +})); // Quote conversion before a path payment contribution -router.get('/quote', requireAuth, contributionQuoteValidation, validateRequest, async (req, res) => { +router.get('/quote', requireAuth, contributionQuoteValidation, validateRequest, asyncHandler(async (req, res) => { /** * @openapi * /api/contributions/quote: @@ -279,9 +280,9 @@ router.get('/quote', requireAuth, contributionQuoteValidation, validateRequest, path: bestPath.path, path_count: paths.length, }); -}); +})); -router.post('/prepare', requireAuth, contributionValidation, validateRequest, async (req, res) => { +router.post('/prepare', requireAuth, contributionValidation, validateRequest, asyncHandler(async (req, res) => { const { campaign_id, amount, send_asset, sender_public_key, display_name } = req.body; if (!sender_public_key) { return res.status(422).json({ @@ -375,9 +376,9 @@ router.post('/prepare', requireAuth, contributionValidation, validateRequest, as error: 'Could not prepare the Stellar transaction right now. Please try again.', }); } -}); +})); -router.post('/submit-signed', requireAuth, async (req, res) => { +router.post('/submit-signed', requireAuth, asyncHandler(async (req, res) => { const { signed_xdr, prepare_token } = req.body; if (!signed_xdr || !prepare_token) { return res.status(400).json({ error: 'signed_xdr and prepare_token are required' }); @@ -437,10 +438,10 @@ router.post('/submit-signed', requireAuth, async (req, res) => { message: 'Transaction submitted', conversion_quote: prepared.conversion_quote || null, }); -}); +})); // Contribute to a campaign (authenticated, custodial) -router.post('/', contributionPostLimiter, requireAuth, contributionValidation, validateRequest, async (req, res) => { +router.post('/', contributionPostLimiter, requireAuth, contributionValidation, validateRequest, asyncHandler(async (req, res) => { /** * @openapi * /api/contributions: @@ -560,6 +561,6 @@ router.post('/', contributionPostLimiter, requireAuth, contributionValidation, v }); } }); -}); +})); module.exports = router; diff --git a/backend/src/routes/stellarTransactions.js b/backend/src/routes/stellarTransactions.js index fb313c7..4747289 100644 --- a/backend/src/routes/stellarTransactions.js +++ b/backend/src/routes/stellarTransactions.js @@ -1,6 +1,7 @@ const router = require('express').Router(); const db = require('../config/database'); const { requireAuth } = require('../middleware/auth'); +const asyncHandler = require('../utils/asyncHandler'); function isPlatformApprover(userId) { if (!process.env.PLATFORM_APPROVER_USER_ID) return false; @@ -20,7 +21,7 @@ async function assertCampaignReportingAccess(req, campaignId) { /** * Reporting index: Stellar transactions auditable by campaign creators and platform operators. */ -router.get('/', requireAuth, async (req, res) => { +router.get('/', requireAuth, asyncHandler(async (req, res) => { const { campaign_id: campaignId, status, limit } = req.query; const max = Math.min(parseInt(limit, 10) || 50, 200); @@ -63,9 +64,9 @@ router.get('/', requireAuth, async (req, res) => { ); res.json(rows); -}); +})); -router.get('/:id', requireAuth, async (req, res) => { +router.get('/:id', requireAuth, asyncHandler(async (req, res) => { const { rows } = await db.query( `SELECT st.*, c.title AS campaign_title, c.creator_id FROM stellar_transactions st @@ -83,6 +84,6 @@ router.get('/:id', requireAuth, async (req, res) => { delete row.creator_id; res.json(row); -}); +})); module.exports = router; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 693699d..e6405d2 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -3,8 +3,9 @@ const db = require('../config/database'); const { requireAuth } = require('../middleware/auth'); const { createKycSession, isKycRequiredForCampaigns } = require('../services/kycProvider'); const { listCreatorCampaigns, listUserContributions } = require('../services/userDashboardService'); +const asyncHandler = require('../utils/asyncHandler'); -router.get('/me', requireAuth, async (req, res) => { +router.get('/me', requireAuth, asyncHandler(async (req, res) => { const { rows } = await db.query( `SELECT id, email, name, wallet_public_key, wallet_type, role, kyc_status, kyc_completed_at, created_at FROM users @@ -13,9 +14,9 @@ router.get('/me', requireAuth, async (req, res) => { ); if (!rows.length) return res.status(404).json({ error: 'User not found' }); res.json({ ...rows[0], kyc_required_for_campaigns: isKycRequiredForCampaigns() }); -}); +})); -router.post('/me/kyc/start', requireAuth, async (req, res) => { +router.post('/me/kyc/start', requireAuth, asyncHandler(async (req, res) => { const { rows } = await db.query( `SELECT id, email, name, role, kyc_status FROM users @@ -58,14 +59,14 @@ router.post('/me/kyc/start', requireAuth, async (req, res) => { } catch (err) { res.status(502).json({ error: err.message || 'Could not start identity verification' }); } -}); +})); -router.get('/me/campaigns', requireAuth, async (req, res) => { +router.get('/me/campaigns', requireAuth, asyncHandler(async (req, res) => { const campaigns = await listCreatorCampaigns(req.user.userId); res.json(campaigns); -}); +})); -router.get('/me/stats', requireAuth, async (req, res) => { +router.get('/me/stats', requireAuth, asyncHandler(async (req, res) => { const { rows } = await db.query( `SELECT COUNT(*)::int AS total_campaigns, @@ -79,12 +80,12 @@ router.get('/me/stats', requireAuth, async (req, res) => { [req.user.userId] ); res.json(rows[0]); -}); +})); -router.get('/me/contributions', requireAuth, async (req, res) => { +router.get('/me/contributions', requireAuth, asyncHandler(async (req, res) => { const rows = await listUserContributions(req.user.userId); if (rows === null) return res.status(404).json({ error: 'User not found' }); res.json(rows); -}); +})); module.exports = router; diff --git a/backend/src/utils/asyncHandler.js b/backend/src/utils/asyncHandler.js new file mode 100644 index 0000000..fdb7897 --- /dev/null +++ b/backend/src/utils/asyncHandler.js @@ -0,0 +1,14 @@ +/** + * Wraps an async Express route handler so that any rejected promise is forwarded + * to Express's next() error pipeline, where it will be caught by errorHandler. + * + * Usage: + * router.get('/path', asyncHandler(async (req, res) => { + * const { rows } = await db.query(...); + * res.json(rows); + * })); + * + * Without this wrapper, an uncaught async rejection in Express 4 causes the + * client to receive an empty/hung response because Express never calls next(err). + */ +module.exports = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); diff --git a/backend/src/utils/asyncHandler.test.js b/backend/src/utils/asyncHandler.test.js new file mode 100644 index 0000000..dd9c6df --- /dev/null +++ b/backend/src/utils/asyncHandler.test.js @@ -0,0 +1,57 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const asyncHandler = require('./asyncHandler'); + +test('asyncHandler passes (req, res, next) to the wrapped function', async () => { + const req = {}; + const res = {}; + let nextCalled = false; + const next = (err) => { nextCalled = err; }; + + const handler = asyncHandler(async (r, s, n) => { + assert.equal(r, req); + assert.equal(s, res); + assert.equal(n, next); + }); + + await handler(req, res, next); + assert.equal(nextCalled, false, 'next should not be called for a successful handler'); +}); + +test('asyncHandler forwards a thrown error to next()', async () => { + const err = new Error('DB connection refused'); + let capturedError; + const next = (e) => { capturedError = e; }; + + const handler = asyncHandler(async () => { + throw err; + }); + + await handler({}, {}, next); + assert.equal(capturedError, err, 'next() should receive the thrown error'); +}); + +test('asyncHandler forwards a rejected promise to next()', async () => { + const err = new Error('Query failed'); + let capturedError; + const next = (e) => { capturedError = e; }; + + const handler = asyncHandler(() => Promise.reject(err)); + + await handler({}, {}, next); + assert.equal(capturedError, err, 'next() should receive the rejection reason'); +}); + +test('asyncHandler does not call next() when handler resolves successfully', async () => { + let nextCallCount = 0; + const next = () => { nextCallCount++; }; + + const handler = asyncHandler(async (_req, res) => { + res.body = 'ok'; + }); + + const res = {}; + await handler({}, res, next); + assert.equal(res.body, 'ok'); + assert.equal(nextCallCount, 0, 'next() must not be called on success'); +}); diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx index 9708717..921f266 100644 --- a/frontend/src/pages/AdminDashboard.jsx +++ b/frontend/src/pages/AdminDashboard.jsx @@ -473,112 +473,52 @@ export default function AdminDashboard() {
${stats.platform_fees_collected}
- - - -| Title | -Creator | -Status | -Action | -
|---|---|---|---|
| {c.title} | -{c.creator_email} | -{c.status} | -- - | -
No milestone activity yet.
- ) : ( -