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
59 changes: 59 additions & 0 deletions __tests__/contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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, [
Expand Down
5 changes: 5 additions & 0 deletions src/common/schema/contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down
48 changes: 48 additions & 0 deletions src/common/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<User, 'id' | 'username' | 'name' | 'email'>;
url: string;
note?: string | null;
}): Promise<void> => {
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,
Expand Down
57 changes: 56 additions & 1 deletion src/schema/contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,21 @@ 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,
contributionActionsArgsSchema,
contributionConnectionArgsSchema,
contributionSubmissionsArgsSchema,
submitContributionActionInputSchema,
suggestContributionCauseArgsSchema,
updateContributionCausePreferencesArgsSchema,
} from '../common/schema/contributions';
import { ContributionAction } from '../entity/contribution/ContributionAction';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1241,6 +1253,49 @@ export const resolvers: IResolvers<unknown, BaseContext> = {

return state;
},
suggestContributionCause: async (
_,
args: z.infer<typeof suggestContributionCauseArgsSchema>,
ctx: AuthContext,
): Promise<GQLEmptyResponse> => {
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: {
Expand Down
Loading