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
21 changes: 21 additions & 0 deletions backend/db/migrations/20260728_funds_released_notifications.sql
Original file line number Diff line number Diff line change
@@ -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 $$;
50 changes: 50 additions & 0 deletions backend/src/emails/fundsReleased.js
Original file line number Diff line number Diff line change
@@ -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 };
28 changes: 14 additions & 14 deletions backend/src/routes/milestones.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 }));
Expand Down
12 changes: 12 additions & 0 deletions backend/src/routes/withdrawals.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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(() => {
Expand Down
8 changes: 8 additions & 0 deletions backend/src/services/emailService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -351,6 +358,7 @@ module.exports = {
sendWithdrawalRejectedEmail,
sendMilestoneReleasedCreatorEmail,
sendMilestoneReleasedContributorEmail,
sendContributorFundsReleasedEmail,
sendMilestoneEvidenceSubmittedAdminEmail,
sendKycApprovedEmail,
sendKycRejectedEmail,
Expand Down
83 changes: 83 additions & 0 deletions backend/src/services/fundReleaseNotifications.js
Original file line number Diff line number Diff line change
@@ -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 };
3 changes: 2 additions & 1 deletion backend/src/services/notificationChannels.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -16,6 +16,7 @@ const CRITICAL_EVENT_TYPES = new Set([
'withdrawal_rejected',
'campaign_failed',
'refund_available',
'funds_released',
'dispute_opened',
'dispute_resolved',
]);
Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/NotificationSettings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down