From dd7158afddb2f8629d79ae3e4801bdf22d909a11 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:47:17 +0300 Subject: [PATCH] feat(contribution): claim the founding award via mutation instead of auto-grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend showed "claimable" as soon as a contributor had any approved points, but the award was only ever auto-granted by a CDC worker reacting to newly-approved submissions — so users whose approved action predated the worker (or any other missed event) could never actually receive it. Replace the auto-grant worker with a user-initiated claimContributionFoundingAward mutation that grants on demand and is safe to retry. --- .infra/common.ts | 4 - __tests__/contributions.ts | 145 ++++++++++++++++++++++- src/common/contribution/founding.ts | 12 +- src/schema/contributions.ts | 131 +++++++++++++++----- src/workers/contributionFoundingAward.ts | 14 --- src/workers/index.ts | 2 - 6 files changed, 251 insertions(+), 57 deletions(-) delete mode 100644 src/workers/contributionFoundingAward.ts diff --git a/.infra/common.ts b/.infra/common.ts index b24f53067a..637e7e6de8 100644 --- a/.infra/common.ts +++ b/.infra/common.ts @@ -267,10 +267,6 @@ export const workers: Worker[] = [ topic: 'api.v1.contribution-action-completed', subscription: 'api.contribution-action-completed-milestone', }, - { - topic: 'api.v1.contribution-action-completed', - subscription: 'api.contribution-action-completed-founding', - }, { topic: 'analytics-api.v1.experiment-allocated', subscription: 'api.experiment-allocated', diff --git a/__tests__/contributions.ts b/__tests__/contributions.ts index 6a1bbb5158..c4d58b0508 100644 --- a/__tests__/contributions.ts +++ b/__tests__/contributions.ts @@ -49,7 +49,11 @@ import { CONTRIBUTION_LAST_MILESTONE_REDIS_KEY, detectContributionMilestones, } from '../src/common/contribution'; -import { grantFoundingContributorAward } from '../src/common/contribution/founding'; +import { + CONTRIBUTION_FOUNDING_AWARD_PRODUCT_ID, + CONTRIBUTION_FOUNDING_LIMIT, + grantFoundingContributorAward, +} from '../src/common/contribution/founding'; import { ContributionFoundingContributor } from '../src/entity/contribution/ContributionFoundingContributor'; import { Product, ProductType } from '../src/entity/Product'; import { systemUser } from '../src/common/utils'; @@ -102,6 +106,17 @@ query ContributionFoundingAward { } `; +const CLAIM_CONTRIBUTION_FOUNDING_AWARD_MUTATION = ` +mutation ClaimContributionFoundingAward { + claimContributionFoundingAward { + totalSpots + claimedCount + isFoundingMember + memberNumber + } +} +`; + const CONTRIBUTION_ACTIONS_QUERY = ` query ContributionActions($categoryId: ID) { contributionActions(categoryId: $categoryId, first: 10) { @@ -393,6 +408,9 @@ beforeEach(async () => { // After the users are gone their award transactions cascade away, so the // founding award product is no longer referenced and can be removed. await con.getRepository(Product).delete({ id: foundingProductId }); + await con + .getRepository(Product) + .delete({ id: CONTRIBUTION_FOUNDING_AWARD_PRODUCT_ID }); remoteConfig.vars.contributionProgram = { enabled: true, @@ -1266,6 +1284,131 @@ it('is a no-op when the award product is missing', async () => { ); }); +const seedRealFoundingProduct = () => + saveFixtures(con, Product, [ + { + id: CONTRIBUTION_FOUNDING_AWARD_PRODUCT_ID, + name: 'Giveback Founding Member', + image: 'https://daily.dev/founding.jpg', + type: ProductType.Award, + value: 1000, + }, + ]); + +const seedApprovedFoundingContribution = async () => { + await saveFixtures(con, ContributionAction, [ + { id: actionId, title: 'Post', points: 10, evidence: {} }, + ]); + await saveFixtures(con, ContributionSubmission, [ + { + userId, + actionId, + status: ContributionSubmissionStatus.Approved, + awardedPoints: 10, + }, + ]); +}; + +it('claims the founding award for an eligible contributor', async () => { + mockNjord(); + await seedRealFoundingProduct(); + await seedApprovedFoundingContribution(); + + const res = await client.mutate(CLAIM_CONTRIBUTION_FOUNDING_AWARD_MUTATION); + + expect(res.errors).toBeUndefined(); + expect(res.data.claimContributionFoundingAward).toEqual({ + totalSpots: CONTRIBUTION_FOUNDING_LIMIT, + claimedCount: 1, + isFoundingMember: true, + memberNumber: 1, + }); + await expect( + con + .getRepository(ContributionFoundingContributor) + .findOneByOrFail({ userId }), + ).resolves.toMatchObject({ userId }); +}); + +it('is idempotent when claiming the founding award again', async () => { + mockNjord(); + await seedRealFoundingProduct(); + await seedApprovedFoundingContribution(); + + await client.mutate(CLAIM_CONTRIBUTION_FOUNDING_AWARD_MUTATION); + const res = await client.mutate(CLAIM_CONTRIBUTION_FOUNDING_AWARD_MUTATION); + + expect(res.errors).toBeUndefined(); + expect(res.data.claimContributionFoundingAward).toEqual({ + totalSpots: CONTRIBUTION_FOUNDING_LIMIT, + claimedCount: 1, + isFoundingMember: true, + memberNumber: 1, + }); + expect( + await con.getRepository(UserTransaction).countBy({ + receiverId: userId, + productId: CONTRIBUTION_FOUNDING_AWARD_PRODUCT_ID, + }), + ).toBe(1); +}); + +it('rejects claiming the founding award without an approved contribution', async () => { + mockNjord(); + await seedRealFoundingProduct(); + + const res = await client.mutate(CLAIM_CONTRIBUTION_FOUNDING_AWARD_MUTATION); + + expect(res.errors?.[0].message).toEqual( + 'Complete a giveback action before claiming the founding award', + ); + expect( + await con + .getRepository(ContributionFoundingContributor) + .countBy({ userId }), + ).toBe(0); +}); + +it('allows claiming the founding award from a zero-point approved action', async () => { + mockNjord(); + await seedRealFoundingProduct(); + await saveFixtures(con, ContributionAction, [ + { id: actionId, title: 'Love action', points: 0, evidence: {} }, + ]); + await saveFixtures(con, ContributionSubmission, [ + { + userId, + actionId, + status: ContributionSubmissionStatus.Approved, + awardedPoints: 0, + }, + ]); + + const res = await client.mutate(CLAIM_CONTRIBUTION_FOUNDING_AWARD_MUTATION); + + expect(res.errors).toBeUndefined(); + expect(res.data.claimContributionFoundingAward).toMatchObject({ + isFoundingMember: true, + memberNumber: 1, + }); +}); + +it('surfaces a distinct error when the award product is misconfigured, not "sold out"', async () => { + mockNjord(); + await seedApprovedFoundingContribution(); + + const res = await client.mutate(CLAIM_CONTRIBUTION_FOUNDING_AWARD_MUTATION); + + expect(res.errors?.[0].message).not.toEqual( + 'All founding spots have been claimed', + ); + expect( + await con.getRepository(ContributionFoundingContributor).countBy({ + userId, + }), + ).toBe(0); +}); + const seedLeaderboardAction = () => saveFixtures(con, ContributionAction, [ { id: actionId, title: 'Post', points: 10, evidence: {} }, diff --git a/src/common/contribution/founding.ts b/src/common/contribution/founding.ts index 78c792d6e9..03f7e64827 100644 --- a/src/common/contribution/founding.ts +++ b/src/common/contribution/founding.ts @@ -16,11 +16,13 @@ export const CONTRIBUTION_FOUNDING_LIMIT = 1000; export const CONTRIBUTION_FOUNDING_AWARD_PRODUCT_ID = '3bbe8325-ceb9-411b-be83-ffba2703ce82'; -// Grants the founding-contributor award (a Product award paid by the system) the -// first time a user contributes, while the campaign is under the cap. Idempotent -// per user via the founding-contributor PK; the whole grant is transactional, so -// a failed Cores transfer rolls back the reservation and retries cleanly. The cap -// is best-effort under concurrency (a small overshoot is acceptable). +// Grants the founding-contributor award (a Product award paid by the system), +// called from the claimContributionFoundingAward mutation once a user has at +// least one approved contribution, while the campaign is under the cap. +// Idempotent per user via the founding-contributor PK; the whole grant is +// transactional, so a failed Cores transfer rolls back the reservation and +// retries cleanly. The cap is best-effort under concurrency (a small overshoot +// is acceptable). export const grantFoundingContributorAward = async ({ con, userId, diff --git a/src/schema/contributions.ts b/src/schema/contributions.ts index c35b765fd7..3cfddfc11c 100644 --- a/src/schema/contributions.ts +++ b/src/schema/contributions.ts @@ -1,8 +1,8 @@ import { IResolvers } from '@graphql-tools/utils'; -import { ValidationError } from 'apollo-server-errors'; +import { ForbiddenError, ValidationError } from 'apollo-server-errors'; import type { GraphQLResolveInfo } from 'graphql'; import type { Connection, ConnectionArguments } from 'graphql-relay'; -import { In, LessThanOrEqual } from 'typeorm'; +import { In, type DataSource } from 'typeorm'; import type z from 'zod'; import { AuthContext, BaseContext, Context } from '../Context'; import { @@ -37,7 +37,10 @@ import { ContributionActionCategory } from '../entity/contribution/ContributionA 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 { + CONTRIBUTION_FOUNDING_LIMIT, + grantFoundingContributorAward, +} from '../common/contribution/founding'; import { ContributionMilestone } from '../entity/contribution/ContributionMilestone'; import { ContributionPayment, @@ -148,6 +151,46 @@ type GQLContributionActionCompleted = { awardedPoints: number; }; +// Shared by the query (public, keyed off the viewer) and the claim mutation +// (which re-reads the state right after granting). +const getFoundingAwardState = async ({ + con, + userId, +}: { + con: DataSource; + userId?: string; +}): Promise => { + const repo = con.getRepository(ContributionFoundingContributor); + const [claimedCount, isFoundingMember] = await Promise.all([ + repo.count(), + userId ? repo.exists({ where: { userId } }) : false, + ]); + + // 1-based grant order (how many founders joined at or before this one). + // Compared entirely in Postgres via a subquery (rather than round-tripping + // the row's createdAt through JS Date, which truncates the DB's microsecond + // precision to milliseconds) so a freshly granted row's own timestamp + // always satisfies "<=" against itself. + const memberNumber = isFoundingMember + ? await repo + .createQueryBuilder('contributor') + .where( + `contributor."createdAt" <= ( + SELECT "createdAt" FROM "contribution_founding_contributor" WHERE "userId" = :userId + )`, + { userId }, + ) + .getCount() + : null; + + return { + totalSpots: CONTRIBUTION_FOUNDING_LIMIT, + claimedCount, + isFoundingMember, + memberNumber, + }; +}; + const toGQLReward = ({ reward, tier, @@ -215,9 +258,10 @@ export const typeDefs = /* GraphQL */ ` """ 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). + contributors, granted via claimContributionFoundingAward once a contributor + has completed at least one 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 claim it). """ type ContributionFoundingAward { totalSpots: Int! @@ -526,6 +570,13 @@ export const typeDefs = /* GraphQL */ ` claimContributionReward(tierId: ID!): UserContributionReward! @auth @contributionEligibility + """ + Claims the founding-contributor award. Requires at least one approved + action; idempotent for an existing founding member. + """ + claimContributionFoundingAward: ContributionFoundingAward! + @auth + @contributionEligibility } type ContributionActionCompleted { @@ -606,32 +657,10 @@ export const resolvers: IResolvers = { _, __, ctx: Context, - ): Promise => { + ): 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, - }; - }, + // own founding membership stays false/null until they sign in and claim it. + getFoundingAwardState({ con: ctx.con, userId: ctx.userId }), contributionActionCategories: async ( _, args: ConnectionArguments, @@ -1172,6 +1201,46 @@ export const resolvers: IResolvers = { return toGQLReward({ reward, tier }); }, + claimContributionFoundingAward: async ( + _, + __, + ctx: AuthContext, + ): Promise => { + const hasApprovedContribution = await ctx.con + .getRepository(ContributionSubmission) + .exists({ + where: { + userId: ctx.userId, + status: ContributionSubmissionStatus.Approved, + }, + }); + + if (!hasApprovedContribution) { + throw new ValidationError( + 'Complete a giveback action before claiming the founding award', + ); + } + + await grantFoundingContributorAward({ con: ctx.con, userId: ctx.userId }); + + const state = await getFoundingAwardState({ + con: ctx.con, + userId: ctx.userId, + }); + + if (!state.isFoundingMember) { + // grantFoundingContributorAward returns false both when the cap is + // genuinely full and when the award Product is missing/misconfigured; + // distinguish them here so a config issue doesn't masquerade as + // "sold out". + if (state.claimedCount >= state.totalSpots) { + throw new ForbiddenError('All founding spots have been claimed'); + } + throw new Error('Founding award is not available right now'); + } + + return state; + }, }, Subscription: { contributionActionCompleted: { diff --git a/src/workers/contributionFoundingAward.ts b/src/workers/contributionFoundingAward.ts deleted file mode 100644 index c9840cdf28..0000000000 --- a/src/workers/contributionFoundingAward.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { grantFoundingContributorAward } from '../common/contribution/founding'; -import { TypedWorker } from './worker'; - -const worker: TypedWorker<'api.v1.contribution-action-completed'> = { - subscription: 'api.contribution-action-completed-founding', - handler: async ({ data }, con): Promise => { - await grantFoundingContributorAward({ - con, - userId: data.submission.userId, - }); - }, -}; - -export default worker; diff --git a/src/workers/index.ts b/src/workers/index.ts index 1c25b3d5c2..63768e9fc7 100644 --- a/src/workers/index.ts +++ b/src/workers/index.ts @@ -20,7 +20,6 @@ import newNotificationRealTime from './newNotificationV2RealTime'; import newHighlightRealTime from './newHighlightRealTime'; import contributionActionCompletedRealTime from './contributionActionCompletedRealTime'; import contributionActionCompletedSlack from './contributionActionCompletedSlack'; -import contributionFoundingAward from './contributionFoundingAward'; import contributionMilestoneReached from './contributionMilestoneReached'; import newNotificationMail from './newNotificationV2Mail'; import newNotificationPush from './newNotificationV2Push'; @@ -183,7 +182,6 @@ export const typedWorkers: BaseTypedWorker[] = [ newHighlightRealTime, contributionActionCompletedRealTime, contributionActionCompletedSlack, - contributionFoundingAward, contributionMilestoneReached, userDeletionCleanup, liveRoomStartedWorker,