From 3c588d02dd3255f68f5a7e3b4f5153b929165a54 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:35:44 +0300 Subject: [PATCH] feat: fulfill contribution rewards --- __tests__/contributions.ts | 90 ++++++++++- src/common/contribution/rewards.ts | 232 +++++++++++++++++++++++++++++ src/common/schema/contributions.ts | 12 ++ src/entity/user/UserTransaction.ts | 1 + src/schema/contributions.ts | 27 +++- 5 files changed, 354 insertions(+), 8 deletions(-) create mode 100644 src/common/contribution/rewards.ts diff --git a/__tests__/contributions.ts b/__tests__/contributions.ts index 7e25e8485e..7cc6f3b131 100644 --- a/__tests__/contributions.ts +++ b/__tests__/contributions.ts @@ -1,3 +1,5 @@ +import { createClient } from '@connectrpc/connect'; +import { Credits } from '@dailydotdev/schema'; import { DataSource } from 'typeorm'; import createOrGetConnection from '../src/db'; import appFunc from '../src'; @@ -6,11 +8,14 @@ import { disposeGraphQLTesting, initializeGraphQLTesting, MockContext, + createMockNjordTransport, saveFixtures, type GraphQLTestClient, type GraphQLTestingState, } from './helpers'; import { User } from '../src/entity/user/User'; +import * as njordCommon from '../src/common/njord'; +import { SubscriptionCycles } from '../src/paddle'; import { ContributionAction } from '../src/entity/contribution/ContributionAction'; import { ContributionActionCategory } from '../src/entity/contribution/ContributionActionCategory'; import { ContributionBlockedUser } from '../src/entity/contribution/ContributionBlockedUser'; @@ -29,6 +34,12 @@ import { ContributionRewardType } from '../src/entity/contribution/ContributionR import { ContributionSponsor } from '../src/entity/contribution/ContributionSponsor'; import { UserContributionCausePreference } from '../src/entity/contribution/UserContributionCausePreference'; import { UserContributionReward } from '../src/entity/contribution/UserContributionReward'; +import { + UserTransaction, + UserTransactionProcessor, + UserTransactionStatus, + UserTransactionType, +} from '../src/entity/user/UserTransaction'; import { remoteConfig } from '../src/remoteConfig'; let con: DataSource; @@ -48,6 +59,7 @@ const causeId = '33333333-3333-4333-8333-333333333333'; const secondCauseId = '33333333-3333-4333-8333-333333333334'; const sponsorId = '33333333-3333-4333-8333-333333333335'; const tierId = '44444444-4444-4444-8444-444444444444'; +const coresTierId = '44444444-4444-4444-8444-444444444445'; const paymentId = '55555555-5555-4555-8555-555555555555'; const CONTRIBUTION_STATUS_QUERY = ` @@ -209,6 +221,7 @@ mutation ClaimContributionReward($tierId: ID!) { claimContributionReward(tierId: $tierId) { status claimedAt + fulfilledAt tier { id title @@ -240,9 +253,13 @@ beforeAll(async () => { }); beforeEach(async () => { + jest.restoreAllMocks(); loggedUser = userId; region = 'US'; + await con.getRepository(UserTransaction).delete({ + referenceType: UserTransactionType.ContributionReward, + }); await con .createQueryBuilder() .delete() @@ -608,7 +625,7 @@ it('updates cause preferences and claims unlocked reward tiers', async () => { ]); expect(claimed.errors).toBeUndefined(); expect(claimed.data.claimContributionReward).toMatchObject({ - status: 'claimed', + status: 'fulfilled', tier: { id: tierId, title: 'Plus boost', @@ -618,6 +635,77 @@ it('updates cause preferences and claims unlocked reward tiers', async () => { }, }); expect(claimed.data.claimContributionReward.claimedAt).toBeTruthy(); + expect(claimed.data.claimContributionReward.fulfilledAt).toBeTruthy(); + + const user = await con.getRepository(User).findOneByOrFail({ id: userId }); + expect(user.subscriptionFlags?.cycle).toEqual(SubscriptionCycles.Monthly); + expect( + new Date(user.subscriptionFlags?.giftExpirationDate ?? 0).getTime(), + ).toBeGreaterThan(Date.now()); +}); + +it('fulfills claimed Cores reward tiers through Njord', async () => { + jest + .spyOn(njordCommon, 'getNjordClient') + .mockImplementation(() => + createClient(Credits, createMockNjordTransport()), + ); + + await saveFixtures(con, ContributionRewardTier, [ + { + id: coresTierId, + title: 'Cores boost', + thresholdPoints: 50, + rewardType: ContributionRewardType.Cores, + metadata: { amount: 25 }, + }, + ]); + 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: coresTierId }, + }); + + expect(claimed.errors).toBeUndefined(); + expect(claimed.data.claimContributionReward).toMatchObject({ + status: 'fulfilled', + tier: { + id: coresTierId, + title: 'Cores boost', + thresholdPoints: 50, + rewardType: 'cores', + metadata: { amount: 25 }, + }, + }); + + await expect( + con.getRepository(UserTransaction).findOneByOrFail({ + receiverId: userId, + referenceId: coresTierId, + referenceType: UserTransactionType.ContributionReward, + }), + ).resolves.toMatchObject({ + processor: UserTransactionProcessor.Njord, + status: UserTransactionStatus.Success, + value: 25, + valueIncFees: 25, + fee: 0, + }); }); it('returns finalized cause totals, user cause stats, and sponsors', async () => { diff --git a/src/common/contribution/rewards.ts b/src/common/contribution/rewards.ts new file mode 100644 index 0000000000..ccabd06c09 --- /dev/null +++ b/src/common/contribution/rewards.ts @@ -0,0 +1,232 @@ +import { ValidationError } from 'apollo-server-errors'; +import { addDays } from 'date-fns'; +import type { EntityManager } from 'typeorm'; +import { v5 } from 'uuid'; +import type z from 'zod'; +import type { AuthContext } from '../../Context'; +import { + contributionCoresRewardMetadataSchema, + contributionPlusDaysRewardMetadataSchema, +} from '../schema/contributions'; +import { updateSubscriptionFlags } from '../utils'; +import { transferCores } from '../njord'; +import { systemUser } from '../utils'; +import { isPlusMember, SubscriptionCycles } from '../../paddle'; +import { SubscriptionStatus } from '../plus'; +import { User } from '../../entity/user/User'; +import { + ContributionRewardTier, + ContributionRewardType, +} from '../../entity/contribution/ContributionRewardTier'; +import { + UserContributionReward, + UserContributionRewardStatus, +} from '../../entity/contribution/UserContributionReward'; +import { + UserTransaction, + UserTransactionProcessor, + UserTransactionStatus, + UserTransactionType, +} from '../../entity/user/UserTransaction'; + +const contributionRewardTransactionNamespace = + '3507776f-51a3-41e6-bd53-3653ecf10690'; + +const parseRewardMetadata = ({ + schema, + metadata, +}: { + schema: TSchema; + metadata: ContributionRewardTier['metadata']; +}): z.infer => { + const result = schema.safeParse(metadata); + + if (!result.success) { + throw new ValidationError('Invalid reward metadata'); + } + + return result.data; +}; + +const markContributionRewardFulfilled = async ({ + con, + reward, + fulfilledAt, +}: { + con: EntityManager; + reward: UserContributionReward; + fulfilledAt: Date; +}): Promise => { + await con.getRepository(UserContributionReward).update( + { + userId: reward.userId, + tierId: reward.tierId, + }, + { + status: UserContributionRewardStatus.Fulfilled, + fulfilledAt, + }, + ); + + return { + ...reward, + status: UserContributionRewardStatus.Fulfilled, + fulfilledAt, + }; +}; + +const fulfillContributionCoresReward = async ({ + con, + ctx, + tier, + reward, + now, +}: { + con: EntityManager; + ctx: AuthContext; + tier: ContributionRewardTier; + reward: UserContributionReward; + now: Date; +}): Promise => { + const { amount } = parseRewardMetadata({ + schema: contributionCoresRewardMetadataSchema, + metadata: tier.metadata, + }); + const transactionId = v5( + `${reward.userId}:${reward.tierId}:cores`, + contributionRewardTransactionNamespace, + ); + const existingTransaction = await con.getRepository(UserTransaction).findOne({ + select: ['id', 'status'], + where: { id: transactionId }, + }); + + if (existingTransaction?.status === UserTransactionStatus.Success) { + return markContributionRewardFulfilled({ con, reward, fulfilledAt: now }); + } + + const transaction = await con.getRepository(UserTransaction).save( + con.getRepository(UserTransaction).create({ + id: transactionId, + processor: UserTransactionProcessor.Njord, + receiverId: reward.userId, + status: UserTransactionStatus.Success, + productId: null, + senderId: systemUser.id, + value: amount, + valueIncFees: amount, + fee: 0, + request: ctx.requestMeta ?? {}, + flags: { + note: `Contribution reward: ${tier.title}`, + }, + referenceId: tier.id, + referenceType: UserTransactionType.ContributionReward, + }), + ); + + await transferCores({ + ctx, + transaction, + entityManager: con, + }); + + return markContributionRewardFulfilled({ con, reward, fulfilledAt: now }); +}; + +const getContributionPlusExpiration = ({ + currentExpiration, + days, + now, +}: { + currentExpiration?: Date | string | null; + days: number; + now: Date; +}): Date => { + const parsedExpiration = currentExpiration + ? new Date(currentExpiration) + : null; + const startsAt = + parsedExpiration && + !Number.isNaN(parsedExpiration.getTime()) && + parsedExpiration > now + ? parsedExpiration + : now; + + return addDays(startsAt, days); +}; + +const fulfillContributionPlusDaysReward = async ({ + con, + tier, + reward, + now, +}: { + con: EntityManager; + tier: ContributionRewardTier; + reward: UserContributionReward; + now: Date; +}): Promise => { + const { days } = parseRewardMetadata({ + schema: contributionPlusDaysRewardMetadataSchema, + metadata: tier.metadata, + }); + const user = await con.getRepository(User).findOne({ + select: ['id', 'subscriptionFlags'], + where: { id: reward.userId }, + }); + + if ( + isPlusMember(user?.subscriptionFlags?.cycle) && + !user?.subscriptionFlags?.giftExpirationDate + ) { + return reward; + } + + const giftExpirationDate = getContributionPlusExpiration({ + currentExpiration: user?.subscriptionFlags?.giftExpirationDate, + days, + now, + }); + const cycle = + days >= 365 ? SubscriptionCycles.Yearly : SubscriptionCycles.Monthly; + + await con.getRepository(User).update(reward.userId, { + subscriptionFlags: updateSubscriptionFlags({ + cycle, + createdAt: user?.subscriptionFlags?.createdAt ?? now, + updatedAt: now, + giftExpirationDate, + status: SubscriptionStatus.Active, + }), + }); + + return markContributionRewardFulfilled({ con, reward, fulfilledAt: now }); +}; + +export const fulfillContributionReward = async ({ + con, + ctx, + tier, + reward, +}: { + con: EntityManager; + ctx: AuthContext; + tier: ContributionRewardTier; + reward: UserContributionReward; +}): Promise => { + if (reward.status === UserContributionRewardStatus.Fulfilled) { + return reward; + } + + const now = new Date(); + + switch (tier.rewardType) { + case ContributionRewardType.Cores: + return fulfillContributionCoresReward({ con, ctx, tier, reward, now }); + case ContributionRewardType.PlusDays: + return fulfillContributionPlusDaysReward({ con, tier, reward, now }); + default: + return reward; + } +}; diff --git a/src/common/schema/contributions.ts b/src/common/schema/contributions.ts index de2f167172..2fad9587dc 100644 --- a/src/common/schema/contributions.ts +++ b/src/common/schema/contributions.ts @@ -69,6 +69,18 @@ export const claimContributionRewardArgsSchema = z.object({ tierId: z.uuid(), }); +export const contributionCoresRewardMetadataSchema = z + .object({ + amount: z.number().int().positive(), + }) + .strict(); + +export const contributionPlusDaysRewardMetadataSchema = z + .object({ + days: z.number().int().positive(), + }) + .strict(); + const contributionMetadataSchema = z .object({}) .catchall(z.unknown()) diff --git a/src/entity/user/UserTransaction.ts b/src/entity/user/UserTransaction.ts index c6cd14fb06..07b79b850b 100644 --- a/src/entity/user/UserTransaction.ts +++ b/src/entity/user/UserTransaction.ts @@ -53,6 +53,7 @@ export enum UserTransactionType { Comment = 'comment', User = 'user', BriefGeneration = 'brief_generation', + ContributionReward = 'contribution_reward', } @Entity() diff --git a/src/schema/contributions.ts b/src/schema/contributions.ts index 3505bb8ef8..bf8824be4c 100644 --- a/src/schema/contributions.ts +++ b/src/schema/contributions.ts @@ -15,6 +15,7 @@ import { validateContributionActionLimits, validateContributionEvidence, } from '../common/contribution'; +import { fulfillContributionReward } from '../common/contribution/rewards'; import { claimContributionRewardArgsSchema, contributionActionsArgsSchema, @@ -865,15 +866,27 @@ export const resolvers: IResolvers = { }); if (existing) { - return toGQLReward({ reward: existing, tier }); + const reward = await fulfillContributionReward({ + con, + ctx, + tier, + reward: existing, + }); + + return toGQLReward({ reward, tier }); } - const reward = await con.getRepository(UserContributionReward).save({ - userId: ctx.userId, - tierId: tier.id, - status: UserContributionRewardStatus.Claimed, - claimedAt: new Date(), - fulfilledAt: null, + 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, + }), }); return toGQLReward({ reward, tier });