Skip to content
Closed
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
164 changes: 164 additions & 0 deletions __tests__/contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
type GraphQLTestingState,
} from './helpers';
import { User } from '../src/entity/user/User';
import { Feature, FeatureType, FeatureValue } from '../src/entity/Feature';
import * as njordCommon from '../src/common/njord';
import { webhooks } from '../src/common/slack';
import { SubscriptionCycles } from '../src/paddle';
import { ContributionAction } from '../src/entity/contribution/ContributionAction';
import { ContributionActionLink } from '../src/entity/contribution/ContributionActionLink';
Expand Down Expand Up @@ -89,6 +91,17 @@ query ContributionStatus {
}
`;

const CONTRIBUTION_FOUNDING_AWARD_QUERY = `
query ContributionFoundingAward {
contributionFoundingAward {
totalSpots
claimedCount
isFoundingMember
memberNumber
}
}
`;

const CONTRIBUTION_ACTIONS_QUERY = `
query ContributionActions($categoryId: ID) {
contributionActions(categoryId: $categoryId, first: 10) {
Expand Down Expand Up @@ -818,6 +831,124 @@ it('fulfills claimed Cores reward tiers through Njord', async () => {
});
});

it('fulfills content-only reward tiers without side effects', async () => {
const patchyTierId = '44444444-4444-4444-8444-444444444446';
await saveFixtures(con, ContributionRewardTier, [
{
id: patchyTierId,
title: 'Picture with Patchy',
thresholdPoints: 50,
rewardType: ContributionRewardType.PatchyPicture,
metadata: {},
},
]);
await saveFixtures(con, ContributionAction, [
{ id: actionId, title: 'Referral', points: 50, evidence: {} },
]);
await saveFixtures(con, ContributionSubmission, [
{
userId,
actionId,
status: ContributionSubmissionStatus.Approved,
awardedPoints: 50,
},
]);

const claimed = await client.mutate(CLAIM_CONTRIBUTION_REWARD_MUTATION, {
variables: { tierId: patchyTierId },
});

expect(claimed.errors).toBeUndefined();
expect(claimed.data.claimContributionReward.status).toBe('fulfilled');
await expect(
con.getRepository(UserTransaction).findOneBy({
receiverId: userId,
referenceId: patchyTierId,
}),
).resolves.toBeNull();
});

it('grants the cause-suggestion right when claiming a suggest_causes reward', async () => {
const suggestTierId = '44444444-4444-4444-8444-444444444447';
await saveFixtures(con, ContributionRewardTier, [
{
id: suggestTierId,
title: 'Suggest a cause',
thresholdPoints: 50,
rewardType: ContributionRewardType.SuggestCauses,
metadata: {},
},
]);
await saveFixtures(con, ContributionAction, [
{ id: actionId, title: 'Referral', points: 50, evidence: {} },
]);
await saveFixtures(con, ContributionSubmission, [
{
userId,
actionId,
status: ContributionSubmissionStatus.Approved,
awardedPoints: 50,
},
]);

const claimed = await client.mutate(CLAIM_CONTRIBUTION_REWARD_MUTATION, {
variables: { tierId: suggestTierId },
});

expect(claimed.errors).toBeUndefined();
expect(claimed.data.claimContributionReward.status).toBe('fulfilled');
await expect(
con.getRepository(Feature).exists({
where: {
userId,
feature: FeatureType.ContributionSuggestCauses,
value: FeatureValue.Allow,
},
}),
).resolves.toBe(true);
});

it('notifies Slack when claiming a store_discount reward', async () => {
const sendSpy = jest
.spyOn(webhooks.contributions, 'send')
.mockResolvedValue(undefined);
const discountTierId = '44444444-4444-4444-8444-444444444448';
await saveFixtures(con, ContributionRewardTier, [
{
id: discountTierId,
title: '50% off the store',
thresholdPoints: 50,
rewardType: ContributionRewardType.StoreDiscount,
metadata: { percent: 50 },
},
]);
await saveFixtures(con, ContributionAction, [
{ id: actionId, title: 'Referral', points: 50, evidence: {} },
]);
await saveFixtures(con, ContributionSubmission, [
{
userId,
actionId,
status: ContributionSubmissionStatus.Approved,
awardedPoints: 50,
},
]);

const claimed = await client.mutate(CLAIM_CONTRIBUTION_REWARD_MUTATION, {
variables: { tierId: discountTierId },
});

expect(claimed.errors).toBeUndefined();
expect(claimed.data.claimContributionReward.status).toBe('fulfilled');
expect(sendSpy).toHaveBeenCalledTimes(1);

// Re-claiming an already-fulfilled reward must not notify again.
await client.mutate(CLAIM_CONTRIBUTION_REWARD_MUTATION, {
variables: { tierId: discountTierId },
});
expect(sendSpy).toHaveBeenCalledTimes(1);
});

it('returns finalized cause totals, user cause stats, and sponsors', async () => {
await seedCauses();
await saveFixtures(con, ContributionPayment, [
Expand Down Expand Up @@ -1035,6 +1166,39 @@ it('grants the founding contributor award, paid by the system', async () => {
});
});

it('exposes founding-award spots and the visitor founding number', async () => {
await saveFixtures(con, ContributionFoundingContributor, [
{ userId: blockedUserId, createdAt: new Date('2026-01-01T00:00:00.000Z') },
{ userId, createdAt: new Date('2026-02-01T00:00:00.000Z') },
]);

const res = await client.query(CONTRIBUTION_FOUNDING_AWARD_QUERY);

expect(res.errors).toBeUndefined();
expect(res.data.contributionFoundingAward).toEqual({
totalSpots: 1000,
claimedCount: 2,
isFoundingMember: true,
memberNumber: 2,
});
});

it('reports no founding membership for a non-member visitor', async () => {
await saveFixtures(con, ContributionFoundingContributor, [
{ userId: blockedUserId, createdAt: new Date('2026-01-01T00:00:00.000Z') },
]);

const res = await client.query(CONTRIBUTION_FOUNDING_AWARD_QUERY);

expect(res.errors).toBeUndefined();
expect(res.data.contributionFoundingAward).toEqual({
totalSpots: 1000,
claimedCount: 1,
isFoundingMember: false,
memberNumber: null,
});
});

it('is idempotent per contributor', async () => {
mockNjord();
await seedFoundingProduct();
Expand Down
38 changes: 38 additions & 0 deletions src/common/contribution/rewards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { systemUser } from '../utils';
import { isPlusMember, SubscriptionCycles } from '../../paddle';
import { SubscriptionStatus } from '../plus';
import { User } from '../../entity/user/User';
import { Feature, FeatureType, FeatureValue } from '../../entity/Feature';
import {
ContributionRewardTier,
ContributionRewardType,
Expand Down Expand Up @@ -204,6 +205,32 @@ const fulfillContributionPlusDaysReward = async ({
return markContributionRewardFulfilled({ con, reward, fulfilledAt: now });
};

const fulfillContributionSuggestCausesReward = async ({
con,
reward,
now,
}: {
con: EntityManager;
reward: UserContributionReward;
now: Date;
}): Promise<UserContributionReward> => {
// Grant the right through the feature table (wired to the feature-flag system),
// idempotent on the (feature, userId) PK so a re-claim is a no-op.
await con
.getRepository(Feature)
.createQueryBuilder()
.insert()
.values({
userId: reward.userId,
feature: FeatureType.ContributionSuggestCauses,
value: FeatureValue.Allow,
})
.orIgnore()
.execute();

return markContributionRewardFulfilled({ con, reward, fulfilledAt: now });
};

export const fulfillContributionReward = async ({
con,
ctx,
Expand All @@ -226,6 +253,17 @@ export const fulfillContributionReward = async ({
return fulfillContributionCoresReward({ con, ctx, tier, reward, now });
case ContributionRewardType.PlusDays:
return fulfillContributionPlusDaysReward({ con, tier, reward, now });
case ContributionRewardType.SuggestCauses:
return fulfillContributionSuggestCausesReward({ con, reward, now });
// The team is notified on Slack to email the coupon / council invite (see
// the claim resolver); content-only rewards (Patchy, joke, trivia) render
// straight from the tier. All just flip the reward to fulfilled.
case ContributionRewardType.StoreDiscount:
case ContributionRewardType.Council:
case ContributionRewardType.PatchyPicture:
case ContributionRewardType.Joke:
case ContributionRewardType.Trivia:
return markContributionRewardFulfilled({ con, reward, fulfilledAt: now });
default:
return reward;
}
Expand Down
6 changes: 6 additions & 0 deletions src/common/schema/contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ export const contributionPlusDaysRewardMetadataSchema = z
})
.strict();

export const contributionStoreDiscountRewardMetadataSchema = z
.object({
percent: z.number().int().positive().max(100),
})
.strict();

const contributionMetadataSchema = z
.object({})
.catchall(z.unknown())
Expand Down
42 changes: 41 additions & 1 deletion src/common/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { UserIntegrationSlack } from '../entity/UserIntegration';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { FastifyRequest } from 'fastify';
import { PropsParameters } from '../types';
import { getAbsoluteDifferenceInDays } from './users';
import { getAbsoluteDifferenceInDays, getUserPermalink } from './users';
import type { ContributionRewardTier } from '../entity/contribution/ContributionRewardTier';
import { concatTextToNewline } from './utils';
import { capitalize } from 'lodash';
import {
Expand Down Expand Up @@ -281,6 +282,45 @@ export const notifyNewPostBoostedSlack = async ({
});
};

// Some contribution rewards (store discount, council access) are fulfilled by a
// human: the team gets pinged here with the contributor's email and emails them
// the coupon / invite. Content and auto-granted rewards don't call this.
export const notifyContributionRewardClaimedSlack = async ({
user,
tier,
}: {
user: Pick<User, 'id' | 'username' | 'name' | 'email'>;
tier: Pick<ContributionRewardTier, 'title' | 'rewardType'>;
}): Promise<void> => {
const displayName = user.name || user.username || user.id;

await webhooks.contributions.send({
text: `${displayName} claimed "${tier.title}" — needs fulfilment`,
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: '🎁 Contribution reward to fulfil',
emoji: true,
},
},
{
type: 'section',
fields: [
{
type: 'mrkdwn',
text: `*User:*\n<${getUserPermalink(user)}|${displayName}>\n\`${user.id}\``,
},
{ type: 'mrkdwn', text: `*Email:*\n${user.email ?? '—'}` },
{ type: 'mrkdwn', text: `*Reward:*\n${tier.title}` },
{ type: 'mrkdwn', text: `*Type:*\n\`${tier.rewardType}\`` },
],
},
],
});
};

export const notifyNewComment = async (
post: Post,
userId: string,
Expand Down
3 changes: 3 additions & 0 deletions src/entity/Feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export enum FeatureType {
Squad = 'squad',
Search = 'search',
Standup = 'standup',
// Granted by claiming a `suggest_causes` contribution reward; gates the right
// to nominate causes for the giveback campaign.
ContributionSuggestCauses = 'contribution_suggest_causes',
}

export enum FeatureValue {
Expand Down
6 changes: 6 additions & 0 deletions src/entity/contribution/ContributionRewardTier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import {
export enum ContributionRewardType {
Cores = 'cores',
PlusDays = 'plus_days',
StoreDiscount = 'store_discount',
SuggestCauses = 'suggest_causes',
Council = 'council',
PatchyPicture = 'patchy_picture',
Joke = 'joke',
Trivia = 'trivia',
Call = 'call',
Privilege = 'privilege',
Custom = 'custom',
Expand Down
Loading
Loading