From dbcefb3c72a28919ff4a7f1edaeaa653eabd0f7f Mon Sep 17 00:00:00 2001 From: kitWarse <278602811+kitWarse@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:07:35 +0100 Subject: [PATCH] feat: notify contributors when funds are released --- .../20260728_funds_released_notifications.sql | 21 +++++ backend/src/emails/fundsReleased.js | 50 +++++++++++ backend/src/routes/milestones.js | 28 +++---- backend/src/routes/withdrawals.js | 12 +++ backend/src/services/emailService.js | 8 ++ .../src/services/fundReleaseNotifications.js | 83 +++++++++++++++++++ backend/src/services/notificationChannels.js | 3 +- frontend/src/pages/NotificationSettings.jsx | 1 + 8 files changed, 191 insertions(+), 15 deletions(-) create mode 100644 backend/db/migrations/20260728_funds_released_notifications.sql create mode 100644 backend/src/emails/fundsReleased.js create mode 100644 backend/src/services/fundReleaseNotifications.js diff --git a/backend/db/migrations/20260728_funds_released_notifications.sql b/backend/db/migrations/20260728_funds_released_notifications.sql new file mode 100644 index 00000000..c87d13c4 --- /dev/null +++ b/backend/db/migrations/20260728_funds_released_notifications.sql @@ -0,0 +1,21 @@ +-- Funds released notifications (#590) +-- Allow users to opt in/out of email preferences for release transparency +-- events. In-app delivery remains the baseline notification channel. +DO $$ +DECLARE + constraint_name TEXT; +BEGIN + SELECT conname INTO constraint_name + FROM pg_constraint + WHERE conrelid = 'notification_preferences'::regclass + AND contype = 'c' + AND pg_get_constraintdef(oid) LIKE '%channel%'; + + IF constraint_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE notification_preferences DROP CONSTRAINT %I', constraint_name); + END IF; + + ALTER TABLE notification_preferences + ADD CONSTRAINT notification_preferences_channel_check + CHECK (channel IN ('in_app', 'email', 'push', 'slack', 'discord', 'sms')); +END $$; diff --git a/backend/src/emails/fundsReleased.js b/backend/src/emails/fundsReleased.js new file mode 100644 index 00000000..6eb987c4 --- /dev/null +++ b/backend/src/emails/fundsReleased.js @@ -0,0 +1,50 @@ +const { renderLayout, heading, paragraph, table, buttonRow } = require("./layout"); +const { getStellarExpertTxUrl } = require("../utils/stellarExplorer"); + +function buildContributorRelease({ + contributorName, + campaignTitle, + campaignUrl, + amount, + asset, + txHash, + usage, + recipient, +}) { + const name = contributorName || "there"; + const explorerUrl = getStellarExpertTxUrl(txHash); + const subject = `Funds released from "${campaignTitle}"`; + const usageText = usage || "Campaign funds were released to the creator."; + + const text = [ + `Hi ${name},`, + "", + `${amount} ${asset} was released from "${campaignTitle}".`, + `Usage: ${usageText}`, + recipient ? `Recipient: ${recipient}` : null, + txHash ? `Transaction: ${explorerUrl}` : null, + "", + `Campaign page: ${campaignUrl}`, + ].filter(Boolean).join("\n"); + + const rows = [ + ["Released", `${amount} ${asset}`], + ["Usage", usageText], + ]; + if (recipient) rows.push(["Recipient", recipient]); + + const html = renderLayout({ + previewText: `${amount} ${asset} released from "${campaignTitle}".`, + bodyHtml: [ + heading("Funds released"), + paragraph(`Hi ${name}, funds from "${campaignTitle}" were released.`), + table(rows), + txHash ? buttonRow("View transaction", explorerUrl) : "", + buttonRow("View campaign", campaignUrl), + ].join(""), + }); + + return { subject, text, html }; +} + +module.exports = { buildContributorRelease }; diff --git a/backend/src/routes/milestones.js b/backend/src/routes/milestones.js index 74f1e563..30bed46e 100644 --- a/backend/src/routes/milestones.js +++ b/backend/src/routes/milestones.js @@ -24,10 +24,10 @@ const { invokeContract, nativeToScVal, releaseMilestone } = require('../services const { uploadMilestoneEvidence } = require('../services/storage'); const { createNotification } = require('../services/notifications'); const { notifyFollowers } = require('../services/campaignFollowService'); +const { notifyContributorFundRelease } = require('../services/fundReleaseNotifications'); const { evaluateCampaign } = require('../services/fraudService'); const { sendMilestoneReleasedCreatorEmail, - sendMilestoneReleasedContributorEmail, sendMilestoneEvidenceSubmittedAdminEmail, } = require('../services/emailService'); const asyncHandler = require('../utils/asyncHandler'); @@ -792,19 +792,19 @@ const approveMilestoneReleaseHandler = async (req, res) => { [req.user.userId, ...contributors.map((contributor) => contributor.id)] ).catch((e) => logger.error('Milestone follower notify failed', { error: e.message })); - return Promise.all( - contributors.map((contributor) => - sendMilestoneReleasedContributorEmail({ - to: contributor.email, - milestoneId: milestone.id, - contributorName: contributor.name, - campaignTitle: milestone.campaign_title, - campaignUrl, - milestoneTitle: milestone.title, - }) - ) - ); - }).catch((e) => logger.error('Milestone contributor email failed', { error: e.message })); + return contributors; + }).catch((e) => logger.error('Milestone follower notify failed', { error: e.message })); + + notifyContributorFundRelease({ + campaignId: milestone.campaign_id, + campaignTitle: milestone.campaign_title, + amount: releaseAmount, + asset: milestone.asset_type, + txHash, + usage: `Milestone "${milestone.title}" was approved and released.`, + recipient: milestone.destination_key, + excludeUserIds: [req.user.userId], + }).catch((e) => logger.error('Contributor fund release notify failed', { error: e.message })); }); evaluateCampaign(milestone.campaign_id).catch(err => logger.error('Fraud evaluate failed in milestone approve', { error: err.message })); diff --git a/backend/src/routes/withdrawals.js b/backend/src/routes/withdrawals.js index ba82ae95..09df8002 100644 --- a/backend/src/routes/withdrawals.js +++ b/backend/src/routes/withdrawals.js @@ -22,6 +22,7 @@ const { sendWithdrawalApprovedEmail, sendWithdrawalRejectedEmail } = require('.. const { emitWebhookEventForUser, WEBHOOK_EVENTS } = require('../services/webhookDispatcher'); const { withDecryptedWalletSecret } = require('../services/walletSecrets'); const { createNotification } = require('../services/notifications'); +const { notifyContributorFundRelease } = require('../services/fundReleaseNotifications'); const { parsePagination } = require('../utils/pagination'); const asyncHandler = require('../utils/asyncHandler'); @@ -558,6 +559,17 @@ const platformApproveHandler = async (req, res) => { body: `Your withdrawal of ${updatedWithdrawalRow.amount} was approved and submitted on-chain.`, link: `/campaigns/${updatedWithdrawalRow.campaign_id}`, }).catch(() => {}); + + notifyContributorFundRelease({ + campaignId: updatedWithdrawalRow.campaign_id, + campaignTitle: cRows[0].title, + amount: updatedWithdrawalRow.amount, + asset: cRows[0].asset_type, + txHash, + usage: 'Creator withdrawal approved by the platform and submitted on-chain.', + recipient: updatedWithdrawalRow.destination_key, + excludeUserIds: [cRows[0].creator_id], + }).catch((err) => logger.error('Contributor withdrawal release notification failed', { error: err.message })); } setImmediate(() => { diff --git a/backend/src/services/emailService.js b/backend/src/services/emailService.js index cf21358b..cd108e6a 100644 --- a/backend/src/services/emailService.js +++ b/backend/src/services/emailService.js @@ -23,6 +23,7 @@ const teamMemberInvitedEmail = require("../emails/teamMemberInvited"); const thankYouEmail = require("../emails/thankYou"); const walletFundingFailedEmail = require("../emails/walletFundingFailed"); const campaignCommentEmail = require("../emails/campaignComment"); +const fundsReleasedEmail = require("../emails/fundsReleased"); let transporter; @@ -214,6 +215,12 @@ async function sendMilestoneReleasedContributorEmail({ to, milestoneId, ...param await sendIdempotent({ dedupeKey: `milestone_released_contributor:${milestoneId}:${to}`, to, subject, text, html }); } +async function sendContributorFundsReleasedEmail({ to, dedupeKey, ...params }) { + if (!to) return; + const { subject, text, html } = fundsReleasedEmail.buildContributorRelease(params); + await sendIdempotent({ dedupeKey, to, subject, text, html }); +} + async function sendMilestoneEvidenceSubmittedAdminEmail({ to, milestoneId, ...params }) { if (!to) return; const { subject, text, html } = milestoneEvidenceSubmittedEmail.buildForAdmin(params); @@ -351,6 +358,7 @@ module.exports = { sendWithdrawalRejectedEmail, sendMilestoneReleasedCreatorEmail, sendMilestoneReleasedContributorEmail, + sendContributorFundsReleasedEmail, sendMilestoneEvidenceSubmittedAdminEmail, sendKycApprovedEmail, sendKycRejectedEmail, diff --git a/backend/src/services/fundReleaseNotifications.js b/backend/src/services/fundReleaseNotifications.js new file mode 100644 index 00000000..4496dd09 --- /dev/null +++ b/backend/src/services/fundReleaseNotifications.js @@ -0,0 +1,83 @@ +const db = require('../config/database'); +const logger = require('../config/logger'); +const { createNotification } = require('./notifications'); +const { sendContributorFundsReleasedEmail } = require('./emailService'); + +async function listContributors(campaignId) { + const { rows } = await db.query( + `SELECT DISTINCT ON (u.id) u.id, u.email, u.name + FROM contributions c + JOIN users u ON u.wallet_public_key = c.sender_public_key + WHERE c.campaign_id = $1 + ORDER BY u.id, c.created_at ASC`, + [campaignId] + ); + return rows; +} + +function buildMessage({ campaignId, campaignTitle, amount, asset, usage, txHash }) { + const bodyParts = [ + `${amount} ${asset} was released from this campaign.`, + usage ? `Usage: ${usage}` : null, + txHash ? `Transaction: ${txHash}` : null, + ].filter(Boolean); + + return { + type: 'funds_released', + title: `${campaignTitle}: funds released`, + body: bodyParts.join(' '), + link: `/campaigns/${campaignId}`, + }; +} + +async function notifyContributorFundRelease({ + campaignId, + campaignTitle, + amount, + asset, + txHash, + usage, + recipient, + excludeUserIds = [], +}) { + const contributors = await listContributors(campaignId); + const excluded = new Set(excludeUserIds.filter(Boolean)); + const message = buildMessage({ campaignId, campaignTitle, amount, asset, usage, txHash }); + const campaignUrl = `${(process.env.FRONTEND_URL || 'http://localhost:5173').replace(/\/$/, '')}/campaigns/${campaignId}`; + + let notified = 0; + await Promise.all( + contributors + .filter((contributor) => !excluded.has(contributor.id)) + .map(async (contributor) => { + try { + await createNotification(contributor.id, message); + if (contributor.email) { + await sendContributorFundsReleasedEmail({ + to: contributor.email, + dedupeKey: `funds_released:${campaignId}:${txHash || 'no-tx'}:${contributor.id}`, + contributorName: contributor.name, + campaignTitle, + campaignUrl, + amount, + asset, + txHash, + usage, + recipient, + }); + } + notified += 1; + } catch (err) { + logger.error('Contributor fund release notification failed', { + campaign_id: campaignId, + user_id: contributor.id, + error: err.message, + }); + } + }) + ); + + return notified; +} + +module.exports = { notifyContributorFundRelease, buildMessage }; diff --git a/backend/src/services/notificationChannels.js b/backend/src/services/notificationChannels.js index 02ed68c1..35d4e64c 100644 --- a/backend/src/services/notificationChannels.js +++ b/backend/src/services/notificationChannels.js @@ -6,7 +6,7 @@ const logger = require('../config/logger'); // (HTTP webhook shape, SMS provider, push provider) from the notification // service, which only decides *whether* and *where* to deliver. -const CHANNELS = ['in_app', 'push', 'slack', 'discord', 'sms']; +const CHANNELS = ['in_app', 'email', 'push', 'slack', 'discord', 'sms']; // Events important enough to bypass quiet-hours batching and deliver // immediately (issue #429: campaign deadline, withdrawal approved, etc). @@ -16,6 +16,7 @@ const CRITICAL_EVENT_TYPES = new Set([ 'withdrawal_rejected', 'campaign_failed', 'refund_available', + 'funds_released', 'dispute_opened', 'dispute_resolved', ]); diff --git a/frontend/src/pages/NotificationSettings.jsx b/frontend/src/pages/NotificationSettings.jsx index 7b786b74..b342f941 100644 --- a/frontend/src/pages/NotificationSettings.jsx +++ b/frontend/src/pages/NotificationSettings.jsx @@ -13,6 +13,7 @@ const CHANNELS = [ const EVENT_TYPES = [ { id: 'campaign_update', label: 'Campaign updates', description: 'New updates posted on campaigns you support' }, { id: 'milestone_completion', label: 'Milestone completions', description: 'Milestones reached or approved' }, + { id: 'funds_released', label: 'Fund release transparency', description: 'When backed campaign funds are released with usage details' }, { id: 'withdrawal', label: 'Withdrawal notifications', description: 'Withdrawal requests, approvals, and rejections' }, { id: 'dispute', label: 'Dispute notifications', description: 'Disputes opened, updated, or resolved' }, { id: 'referral_reward', label: 'Referral rewards', description: 'When a referral contribution is confirmed' },