diff --git a/__tests__/contributions.ts b/__tests__/contributions.ts index 46951caec6..6a1bbb5158 100644 --- a/__tests__/contributions.ts +++ b/__tests__/contributions.ts @@ -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'; @@ -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) { @@ -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, [ @@ -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(); diff --git a/src/common/contribution/rewards.ts b/src/common/contribution/rewards.ts index ccabd06c09..adea224be5 100644 --- a/src/common/contribution/rewards.ts +++ b/src/common/contribution/rewards.ts @@ -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, @@ -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 => { + // 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, @@ -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; } diff --git a/src/common/schema/contributions.ts b/src/common/schema/contributions.ts index 290a0f1019..191a3faece 100644 --- a/src/common/schema/contributions.ts +++ b/src/common/schema/contributions.ts @@ -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()) diff --git a/src/common/slack.ts b/src/common/slack.ts index 8f57bb4741..d2edd86a02 100644 --- a/src/common/slack.ts +++ b/src/common/slack.ts @@ -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 { @@ -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; + tier: Pick; +}): Promise => { + 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, diff --git a/src/entity/Feature.ts b/src/entity/Feature.ts index db90fdf443..d4fe93cba1 100644 --- a/src/entity/Feature.ts +++ b/src/entity/Feature.ts @@ -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 { diff --git a/src/entity/contribution/ContributionRewardTier.ts b/src/entity/contribution/ContributionRewardTier.ts index 75ec8fec21..fb9fcace55 100644 --- a/src/entity/contribution/ContributionRewardTier.ts +++ b/src/entity/contribution/ContributionRewardTier.ts @@ -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', diff --git a/src/schema/contributions.ts b/src/schema/contributions.ts index 0e9a3290a1..c35b765fd7 100644 --- a/src/schema/contributions.ts +++ b/src/schema/contributions.ts @@ -2,7 +2,7 @@ import { IResolvers } from '@graphql-tools/utils'; import { ValidationError } from 'apollo-server-errors'; import type { GraphQLResolveInfo } from 'graphql'; import type { Connection, ConnectionArguments } from 'graphql-relay'; -import { In } from 'typeorm'; +import { In, LessThanOrEqual } from 'typeorm'; import type z from 'zod'; import { AuthContext, BaseContext, Context } from '../Context'; import { @@ -20,6 +20,9 @@ import { validateContributionEvidence, } from '../common/contribution'; import { fulfillContributionReward } from '../common/contribution/rewards'; +import { notifyContributionRewardClaimedSlack } from '../common/slack'; +import { logger } from '../logger'; +import { User } from '../entity/user/User'; import { claimContributionRewardArgsSchema, contributionActionLinksArgsSchema, @@ -33,6 +36,8 @@ import { ContributionAction } from '../entity/contribution/ContributionAction'; import { ContributionActionCategory } from '../entity/contribution/ContributionActionCategory'; import { ContributionActionLink } from '../entity/contribution/ContributionActionLink'; import { ContributionCause } from '../entity/contribution/ContributionCause'; +import { ContributionFoundingContributor } from '../entity/contribution/ContributionFoundingContributor'; +import { CONTRIBUTION_FOUNDING_LIMIT } from '../common/contribution/founding'; import { ContributionMilestone } from '../entity/contribution/ContributionMilestone'; import { ContributionPayment, @@ -111,6 +116,13 @@ type GQLContributionStatus = { userPoints: number | null; }; +type GQLContributionFoundingAward = { + totalSpots: number; + claimedCount: number; + isFoundingMember: boolean; + memberNumber: number | null; +}; + type GQLUserContributionReward = Pick< UserContributionReward, 'status' | 'claimedAt' | 'fulfilledAt' @@ -159,6 +171,12 @@ export const typeDefs = /* GraphQL */ ` enum ContributionRewardType { cores plus_days + store_discount + suggest_causes + council + patchy_picture + joke + trivia call privilege custom @@ -195,6 +213,26 @@ export const typeDefs = /* GraphQL */ ` userPoints: Int } + """ + The founding-contributor award: a one-time, capped gift for the first N + contributors, granted automatically on a contributor's first approved action. + Campaign-wide fields render for everyone; the visitor's own membership is null + until they sign in (and stays false/null until they become a founder). + """ + type ContributionFoundingAward { + totalSpots: Int! + claimedCount: Int! + """ + Whether the visitor is a founding contributor. False for anonymous visitors. + """ + isFoundingMember: Boolean! + """ + The visitor's founding number (1-based, by grant order). Null unless they are + a founding contributor. + """ + memberNumber: Int + } + type ContributionActionMetadata { platform: String instructions: String @@ -405,6 +443,7 @@ export const typeDefs = /* GraphQL */ ` extend type Query { contributionStatus: ContributionStatus! + contributionFoundingAward: ContributionFoundingAward! contributionActionCategories( first: Int after: String @@ -563,6 +602,36 @@ export const resolvers: IResolvers = { userPoints, }; }, + contributionFoundingAward: async ( + _, + __, + ctx: Context, + ): Promise => { + // Public query: the spots-taken counter renders for everyone; the visitor's + // own founding membership stays false/null until they sign in and qualify. + const repo = ctx.con.getRepository(ContributionFoundingContributor); + const { userId } = ctx; + const [claimedCount, membership] = await Promise.all([ + repo.count(), + userId + ? repo.findOne({ select: ['userId', 'createdAt'], where: { userId } }) + : null, + ]); + + // 1-based grant order (how many founders joined at or before this one). + const memberNumber = membership + ? await repo.countBy({ + createdAt: LessThanOrEqual(membership.createdAt), + }) + : null; + + return { + totalSpots: CONTRIBUTION_FOUNDING_LIMIT, + claimedCount, + isFoundingMember: !!membership, + memberNumber, + }; + }, contributionActionCategories: async ( _, args: ConnectionArguments, @@ -1016,62 +1085,92 @@ export const resolvers: IResolvers = { args, ); - return ctx.con.transaction(async (con) => { - const tier = await con.getRepository(ContributionRewardTier).findOne({ - where: { - id: tierId, - active: true, - }, - }); + const { tier, reward, newlyFulfilled } = await ctx.con.transaction( + async (con) => { + const rewardTier = await con + .getRepository(ContributionRewardTier) + .findOne({ + where: { + id: tierId, + active: true, + }, + }); - if (!tier) { - throw new NotFoundError('Contribution reward tier not found'); - } + if (!rewardTier) { + throw new NotFoundError('Contribution reward tier not found'); + } - const userPoints = await getApprovedPointsSum({ - con, - userId: ctx.userId, - }); + const userPoints = await getApprovedPointsSum({ + con, + userId: ctx.userId, + }); - if (userPoints < tier.thresholdPoints) { - throw new ValidationError('Reward threshold has not been reached'); - } + if (userPoints < rewardTier.thresholdPoints) { + throw new ValidationError('Reward threshold has not been reached'); + } - const existing = await con - .getRepository(UserContributionReward) - .findOne({ - where: { - userId: ctx.userId, - tierId: tier.id, - }, - }); + const existing = await con + .getRepository(UserContributionReward) + .findOne({ + where: { + userId: ctx.userId, + tierId: rewardTier.id, + }, + }); + const wasFulfilled = + existing?.status === UserContributionRewardStatus.Fulfilled; - if (existing) { - const reward = await fulfillContributionReward({ + const claimedReward = await fulfillContributionReward({ con, ctx, - tier, - reward: existing, + tier: rewardTier, + reward: + existing ?? + (await con.getRepository(UserContributionReward).save({ + userId: ctx.userId, + tierId: rewardTier.id, + status: UserContributionRewardStatus.Claimed, + claimedAt: new Date(), + fulfilledAt: null, + })), }); - return toGQLReward({ reward, tier }); - } + return { + tier: rewardTier, + reward: claimedReward, + newlyFulfilled: + !wasFulfilled && + claimedReward.status === UserContributionRewardStatus.Fulfilled, + }; + }, + ); - const reward = await fulfillContributionReward({ - con, - ctx, - tier, - reward: await con.getRepository(UserContributionReward).save({ - userId: ctx.userId, - tierId: tier.id, - status: UserContributionRewardStatus.Claimed, - claimedAt: new Date(), - fulfilledAt: null, - }), - }); + // Coupon / council rewards are fulfilled by a human — ping the team once, + // after the claim has committed, so a failed claim never notifies and a + // Slack outage never fails the claim. + const slackNotifyTypes = [ + ContributionRewardType.StoreDiscount, + ContributionRewardType.Council, + ]; + if (newlyFulfilled && slackNotifyTypes.includes(tier.rewardType)) { + try { + const user = await ctx.con.getRepository(User).findOne({ + select: ['id', 'username', 'name', 'email'], + where: { id: ctx.userId }, + }); - return toGQLReward({ reward, tier }); - }); + if (user) { + await notifyContributionRewardClaimedSlack({ user, tier }); + } + } catch (err) { + logger.error( + { err, tierId: tier.id, userId: ctx.userId }, + 'failed to notify Slack of contribution reward claim', + ); + } + } + + return toGQLReward({ reward, tier }); }, }, Subscription: {