From 10915d23ded5e2f872e2fe5d7a68faab254821db Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:23:21 +0300 Subject: [PATCH] feat(contribution): add suggestContributionCause mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contributors who unlocked the suggest_causes reward can nominate a cause by URL (with an optional note). The suggestion isn't stored — it's posted to the contributions Slack channel for the team to review. Gated on the reward's feature access; a failed Slack send surfaces as an error to retry. --- __tests__/contributions.ts | 59 ++++++++++++++++++++++++++++++ src/common/schema/contributions.ts | 5 +++ src/common/slack.ts | 48 ++++++++++++++++++++++++ src/schema/contributions.ts | 57 ++++++++++++++++++++++++++++- 4 files changed, 168 insertions(+), 1 deletion(-) diff --git a/__tests__/contributions.ts b/__tests__/contributions.ts index c4d58b0508..a985bafce5 100644 --- a/__tests__/contributions.ts +++ b/__tests__/contributions.ts @@ -331,6 +331,14 @@ mutation ClaimContributionReward($tierId: ID!) { } `; +const SUGGEST_CONTRIBUTION_CAUSE_MUTATION = ` +mutation SuggestContributionCause($url: String!, $note: String) { + suggestContributionCause(url: $url, note: $note) { + _ + } +} +`; + beforeAll(async () => { con = await createOrGetConnection(); const app = await appFunc(); @@ -967,6 +975,57 @@ it('notifies Slack when claiming a store_discount reward', async () => { expect(sendSpy).toHaveBeenCalledTimes(1); }); +it('posts a suggested cause to Slack for a contributor who unlocked the reward', async () => { + const sendSpy = jest + .spyOn(webhooks.contributions, 'send') + .mockResolvedValue(undefined); + await con.getRepository(Feature).save({ + userId, + feature: FeatureType.ContributionSuggestCauses, + value: FeatureValue.Allow, + }); + + const res = await client.mutate(SUGGEST_CONTRIBUTION_CAUSE_MUTATION, { + variables: { url: 'https://example.org', note: 'they do great work' }, + }); + + expect(res.errors).toBeUndefined(); + expect(res.data.suggestContributionCause).toEqual({ _: true }); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining('https://example.org'), + }), + ); +}); + +it('rejects a cause suggestion when the reward is not unlocked', async () => { + const sendSpy = jest + .spyOn(webhooks.contributions, 'send') + .mockResolvedValue(undefined); + + const res = await client.mutate(SUGGEST_CONTRIBUTION_CAUSE_MUTATION, { + variables: { url: 'https://example.org' }, + }); + + expect(res.errors?.[0].extensions?.code).toBe('FORBIDDEN'); + expect(sendSpy).not.toHaveBeenCalled(); +}); + +it('rejects a malformed cause url', async () => { + await con.getRepository(Feature).save({ + userId, + feature: FeatureType.ContributionSuggestCauses, + value: FeatureValue.Allow, + }); + + const res = await client.mutate(SUGGEST_CONTRIBUTION_CAUSE_MUTATION, { + variables: { url: 'not-a-url' }, + }); + + expect(res.errors).toBeTruthy(); +}); + it('returns finalized cause totals, user cause stats, and sponsors', async () => { await seedCauses(); await saveFixtures(con, ContributionPayment, [ diff --git a/src/common/schema/contributions.ts b/src/common/schema/contributions.ts index 191a3faece..a344385b65 100644 --- a/src/common/schema/contributions.ts +++ b/src/common/schema/contributions.ts @@ -72,6 +72,11 @@ export const updateContributionCausePreferencesArgsSchema = z.object({ causeIds: z.array(z.uuid()).max(50), }); +export const suggestContributionCauseArgsSchema = z.object({ + url: z.url(), + note: z.string().trim().min(1).max(1000).nullish(), +}); + export const claimContributionRewardArgsSchema = z.object({ tierId: z.uuid(), }); diff --git a/src/common/slack.ts b/src/common/slack.ts index d2edd86a02..e77d751849 100644 --- a/src/common/slack.ts +++ b/src/common/slack.ts @@ -321,6 +321,54 @@ export const notifyContributionRewardClaimedSlack = async ({ }); }; +// A contributor who unlocked the "suggest a cause" reward nominated a nonprofit +// or open-source fund. We don't store it — the team reviews the link here and +// decides whether to add it to the vetted causes. +export const notifyContributionCauseSuggestedSlack = async ({ + user, + url, + note, +}: { + user: Pick; + url: string; + note?: string | null; +}): Promise => { + const displayName = user.name || user.username || user.id; + + await webhooks.contributions.send({ + text: `${displayName} suggested a cause: ${url}`, + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: '💡 New cause suggestion to review', + emoji: true, + }, + }, + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: `*From:*\n<${getUserPermalink(user)}|${displayName}>\n\`${user.id}\``, + }, + { type: 'mrkdwn', text: `*Email:*\n${user.email ?? '—'}` }, + { type: 'mrkdwn', text: `*Cause:*\n<${url}|${url}>` }, + ], + }, + ...(note + ? [ + { + type: 'section' as const, + text: { type: 'mrkdwn' as const, text: `*Note:*\n${note}` }, + }, + ] + : []), + ], + }); +}; + export const notifyNewComment = async ( post: Post, userId: string, diff --git a/src/schema/contributions.ts b/src/schema/contributions.ts index 3cfddfc11c..429d6222d6 100644 --- a/src/schema/contributions.ts +++ b/src/schema/contributions.ts @@ -20,9 +20,13 @@ import { validateContributionEvidence, } from '../common/contribution'; import { fulfillContributionReward } from '../common/contribution/rewards'; -import { notifyContributionRewardClaimedSlack } from '../common/slack'; +import { + notifyContributionCauseSuggestedSlack, + notifyContributionRewardClaimedSlack, +} from '../common/slack'; import { logger } from '../logger'; import { User } from '../entity/user/User'; +import { Feature, FeatureType, FeatureValue } from '../entity/Feature'; import { claimContributionRewardArgsSchema, contributionActionLinksArgsSchema, @@ -30,6 +34,7 @@ import { contributionConnectionArgsSchema, contributionSubmissionsArgsSchema, submitContributionActionInputSchema, + suggestContributionCauseArgsSchema, updateContributionCausePreferencesArgsSchema, } from '../common/schema/contributions'; import { ContributionAction } from '../entity/contribution/ContributionAction'; @@ -567,6 +572,13 @@ export const typeDefs = /* GraphQL */ ` updateContributionCausePreferences(causeIds: [ID!]!): EmptyResponse! @auth @contributionEligibility + """ + Nominates a cause (by URL, with an optional note) for the team to review. + Not stored — the suggestion is posted to Slack for a human decision. + """ + suggestContributionCause(url: String!, note: String): EmptyResponse! + @auth + @contributionEligibility claimContributionReward(tierId: ID!): UserContributionReward! @auth @contributionEligibility @@ -1241,6 +1253,49 @@ export const resolvers: IResolvers = { return state; }, + suggestContributionCause: async ( + _, + args: z.infer, + ctx: AuthContext, + ): Promise => { + const { url, note } = parseContributionArgs( + suggestContributionCauseArgsSchema, + args, + ); + + // Nominating a cause is a reward: only contributors who claimed the + // `suggest_causes` tier hold the feature access it grants (see + // fulfillContributionSuggestCausesReward). + const canSuggest = await ctx.con.getRepository(Feature).exists({ + where: { + userId: ctx.userId, + feature: FeatureType.ContributionSuggestCauses, + value: FeatureValue.Allow, + }, + }); + + if (!canSuggest) { + throw new ForbiddenError( + 'Unlock the suggest-a-cause reward to nominate a cause', + ); + } + + const user = await ctx.con.getRepository(User).findOne({ + select: ['id', 'username', 'name', 'email'], + where: { id: ctx.userId }, + }); + + if (!user) { + throw new NotFoundError('User not found'); + } + + // The Slack post is the only place the suggestion lands (no DB row), so a + // failed send must surface as an error the caller can retry — don't + // swallow it the way reward-claim notifications do. + await notifyContributionCauseSuggestedSlack({ user, url, note }); + + return { _: true }; + }, }, Subscription: { contributionActionCompleted: {