Skip to content

Commit f1668d7

Browse files
authored
feat(contributions): award referral points when invited friend activates (#3952)
1 parent 1d4d4fa commit f1668d7

6 files changed

Lines changed: 357 additions & 2 deletions

File tree

.infra/common.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ export const workers: Worker[] = [
2929
topic: 'user-updated',
3030
subscription: 'api.user-updated-cio',
3131
},
32+
{
33+
topic: 'user-updated',
34+
subscription: 'api.user-activated-contribution-referral',
35+
},
3236
{
3337
topic: 'user-updated',
3438
subscription: 'api.user-updated-plus-subscribed-squad',
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { DataSource, In } from 'typeorm';
2+
import worker from '../../src/workers/userActivatedContributionReferral';
3+
import { ChangeObject } from '../../src/types';
4+
import { expectSuccessfulTypedBackground, saveFixtures } from '../helpers';
5+
import { User } from '../../src/entity';
6+
import { PubSubSchema } from '../../src/common';
7+
import { typedWorkers } from '../../src/workers';
8+
import createOrGetConnection from '../../src/db';
9+
import { remoteConfig } from '../../src/remoteConfig';
10+
import {
11+
ContributionAction,
12+
ContributionAssistType,
13+
} from '../../src/entity/contribution/ContributionAction';
14+
import { ContributionCause } from '../../src/entity/contribution/ContributionCause';
15+
import { UserContributionCausePreference } from '../../src/entity/contribution/UserContributionCausePreference';
16+
import { ContributionBlockedUser } from '../../src/entity/contribution/ContributionBlockedUser';
17+
import {
18+
ContributionSubmission,
19+
ContributionSubmissionStatus,
20+
} from '../../src/entity/contribution/ContributionSubmission';
21+
22+
let con: DataSource;
23+
24+
const referrerId = '99999999-9999-4999-8999-999999999990';
25+
const refereeId = '99999999-9999-4999-8999-999999999991';
26+
const causeId = '33333333-3333-4333-8333-333333333333';
27+
const actionId = '22222222-2222-4222-8222-222222222222';
28+
29+
beforeAll(async () => {
30+
con = await createOrGetConnection();
31+
});
32+
33+
const seedReferralAction = async (
34+
overrides: Partial<ContributionAction> = {},
35+
) => {
36+
await saveFixtures(con, ContributionAction, [
37+
{
38+
id: actionId,
39+
title: 'Invite a friend to daily.dev',
40+
points: 150,
41+
evidence: {},
42+
metadata: { assistType: ContributionAssistType.ReferralLink },
43+
...overrides,
44+
},
45+
]);
46+
};
47+
48+
beforeEach(async () => {
49+
await con
50+
.getRepository(ContributionSubmission)
51+
.delete({ userId: referrerId });
52+
await con
53+
.getRepository(UserContributionCausePreference)
54+
.delete({ userId: referrerId });
55+
await con
56+
.getRepository(ContributionBlockedUser)
57+
.delete({ userId: referrerId });
58+
await con.getRepository(ContributionAction).delete({ id: actionId });
59+
await con.getRepository(ContributionCause).delete({ id: causeId });
60+
await con.getRepository(User).delete({ id: In([referrerId, refereeId]) });
61+
62+
remoteConfig.vars.contributionProgram = {
63+
enabled: true,
64+
allowedCountries: ['US'],
65+
currentCycleTargetPoints: 10000,
66+
};
67+
68+
await saveFixtures(con, User, [
69+
{ id: referrerId, reputation: 10 },
70+
{ id: refereeId, referralId: referrerId, reputation: 10 },
71+
]);
72+
await saveFixtures(con, ContributionCause, [{ id: causeId, title: 'OSS' }]);
73+
await saveFixtures(con, UserContributionCausePreference, [
74+
{ userId: referrerId, causeId },
75+
]);
76+
});
77+
78+
const inactiveReferee: ChangeObject<User> = {
79+
id: refereeId,
80+
referralId: referrerId,
81+
infoConfirmed: false,
82+
emailConfirmed: false,
83+
} as unknown as ChangeObject<User>;
84+
85+
const activeReferee: ChangeObject<User> = {
86+
...inactiveReferee,
87+
infoConfirmed: true,
88+
emailConfirmed: true,
89+
};
90+
91+
const runActivation = (
92+
newProfile: ChangeObject<User> = activeReferee,
93+
user: ChangeObject<User> = inactiveReferee,
94+
) =>
95+
expectSuccessfulTypedBackground(worker, {
96+
newProfile,
97+
user,
98+
} as unknown as PubSubSchema['user-updated']);
99+
100+
const getReferrerSubmissions = () =>
101+
con
102+
.getRepository(ContributionSubmission)
103+
.find({ where: { userId: referrerId } });
104+
105+
describe('userActivatedContributionReferral', () => {
106+
it('should be registered', () => {
107+
const registered = typedWorkers.find(
108+
(item) => item.subscription === worker.subscription,
109+
);
110+
expect(registered).toBeDefined();
111+
});
112+
113+
it('awards the referrer when the referee activates', async () => {
114+
await seedReferralAction();
115+
116+
await runActivation();
117+
118+
const submissions = await getReferrerSubmissions();
119+
expect(submissions).toHaveLength(1);
120+
expect(submissions[0]).toMatchObject({
121+
actionId,
122+
status: ContributionSubmissionStatus.Approved,
123+
awardedPoints: 150,
124+
flags: { refereeId },
125+
});
126+
});
127+
128+
it('does not award when there is no activation transition', async () => {
129+
await seedReferralAction();
130+
131+
await runActivation(activeReferee, activeReferee);
132+
133+
expect(await getReferrerSubmissions()).toHaveLength(0);
134+
});
135+
136+
it('does not award when the referee has no referrer', async () => {
137+
await seedReferralAction();
138+
139+
const noReferral = { ...activeReferee, referralId: null };
140+
await runActivation(
141+
noReferral as unknown as ChangeObject<User>,
142+
{ ...inactiveReferee, referralId: null } as unknown as ChangeObject<User>,
143+
);
144+
145+
expect(await getReferrerSubmissions()).toHaveLength(0);
146+
});
147+
148+
it('does not award when the referrer has not joined the campaign', async () => {
149+
await seedReferralAction();
150+
await con
151+
.getRepository(UserContributionCausePreference)
152+
.delete({ userId: referrerId });
153+
154+
await runActivation();
155+
156+
expect(await getReferrerSubmissions()).toHaveLength(0);
157+
});
158+
159+
it('does not award when no referral action is configured', async () => {
160+
await runActivation();
161+
162+
expect(await getReferrerSubmissions()).toHaveLength(0);
163+
});
164+
165+
it('is idempotent across redelivered activation events', async () => {
166+
await seedReferralAction();
167+
168+
await runActivation();
169+
await runActivation();
170+
171+
expect(await getReferrerSubmissions()).toHaveLength(1);
172+
});
173+
174+
it('respects the action cap', async () => {
175+
await seedReferralAction({ maxPerUser: 1 });
176+
await con.getRepository(ContributionSubmission).save({
177+
userId: referrerId,
178+
actionId,
179+
status: ContributionSubmissionStatus.Approved,
180+
awardedPoints: 150,
181+
evidence: {},
182+
flags: { refereeId: 'some-other-referee' },
183+
});
184+
185+
await runActivation();
186+
187+
expect(await getReferrerSubmissions()).toHaveLength(1);
188+
});
189+
});

src/common/contribution/index.ts

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,19 @@ import {
66
contributionSubmissionEvidenceSchema,
77
} from '../schema/contributions';
88
import { ContributionBlockedUser } from '../../entity/contribution/ContributionBlockedUser';
9-
import type {
9+
import {
1010
ContributionAction,
11-
ContributionEvidenceSchema,
11+
ContributionAssistType,
1212
} from '../../entity/contribution/ContributionAction';
13+
import type { ContributionEvidenceSchema } from '../../entity/contribution/ContributionAction';
1314
import {
1415
ContributionPayment,
1516
ContributionPaymentStatus,
1617
} from '../../entity/contribution/ContributionPayment';
1718
import { ContributionPaymentAllocation } from '../../entity/contribution/ContributionPaymentAllocation';
1819
import { ContributionSubmissionStatus } from '../../entity/contribution/ContributionSubmission';
1920
import { ContributionSubmission } from '../../entity/contribution/ContributionSubmission';
21+
import { UserContributionCausePreference } from '../../entity/contribution/UserContributionCausePreference';
2022
import { remoteConfig } from '../../remoteConfig';
2123

2224
const ACTIVE_STATUSES_FOR_LIMITS = [
@@ -665,3 +667,89 @@ export const validateContributionActionLimits = async ({
665667
throw new ValidationError('Action is still cooling down');
666668
}
667669
};
670+
671+
export const REFERRAL_CONTRIBUTION_REFEREE_FLAG = 'refereeId';
672+
673+
const findReferralContributionAction = (
674+
con: EntityManager,
675+
): Promise<ContributionAction | null> =>
676+
con
677+
.getRepository(ContributionAction)
678+
.createQueryBuilder('action')
679+
.where('action.active = true')
680+
.andWhere(`action.metadata->>'assistType' = :assistType`, {
681+
assistType: ContributionAssistType.ReferralLink,
682+
})
683+
.orderBy('action."sortOrder"', 'ASC')
684+
.addOrderBy('action."createdAt"', 'ASC')
685+
.getOne();
686+
687+
// Credits a referrer with contribution points when a friend they invited
688+
// activates. Gated to referrers who joined the giveback campaign (picked
689+
// causes). Idempotent per referee via the partial unique index on submission
690+
// flags, so a redelivered activation event never double-credits.
691+
export const awardReferralContribution = async ({
692+
con,
693+
referrerId,
694+
refereeId,
695+
}: {
696+
con: EntityManager;
697+
referrerId: string;
698+
refereeId: string;
699+
}): Promise<boolean> => {
700+
if (!getContributionConfig().enabled) {
701+
return false;
702+
}
703+
704+
const [joinedCampaign, blocked] = await Promise.all([
705+
con
706+
.getRepository(UserContributionCausePreference)
707+
.exists({ where: { userId: referrerId } }),
708+
con
709+
.getRepository(ContributionBlockedUser)
710+
.exists({ where: { userId: referrerId } }),
711+
]);
712+
713+
if (!joinedCampaign || blocked) {
714+
return false;
715+
}
716+
717+
const action = await findReferralContributionAction(con);
718+
719+
// A referral action must be rewardable: skip "for love" (0-point) configs.
720+
if (!action || action.points <= 0 || action.metadata?.isLoveAction) {
721+
return false;
722+
}
723+
724+
// Best-effort cap: the per-referee unique index guarantees one award per
725+
// friend, but the cap is a soft check — concurrent activations from different
726+
// referees could each pass it. Acceptable since activation is a rare one-shot.
727+
if (action.maxPerUser !== null && action.maxPerUser !== undefined) {
728+
const usage = await getContributionActionUsage({
729+
con,
730+
userId: referrerId,
731+
actionId: action.id,
732+
});
733+
734+
if (usage.count >= action.maxPerUser) {
735+
return false;
736+
}
737+
}
738+
739+
const result = await con
740+
.getRepository(ContributionSubmission)
741+
.createQueryBuilder()
742+
.insert()
743+
.values({
744+
userId: referrerId,
745+
actionId: action.id,
746+
status: ContributionSubmissionStatus.Approved,
747+
awardedPoints: action.points,
748+
evidence: {},
749+
flags: { [REFERRAL_CONTRIBUTION_REFEREE_FLAG]: refereeId },
750+
})
751+
.orIgnore()
752+
.execute();
753+
754+
return (result.identifiers?.length ?? 0) > 0;
755+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddContributionReferralIdempotency1782200000000
4+
implements MigrationInterface
5+
{
6+
name = 'AddContributionReferralIdempotency1782200000000';
7+
8+
public async up(queryRunner: QueryRunner): Promise<void> {
9+
// Guarantees a referrer is credited at most once per referee, even if the
10+
// activation event is redelivered. Partial so it only applies to
11+
// referral-award submissions (those carrying a refereeId flag).
12+
await queryRunner.query(/* sql */ `
13+
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_contribution_submission_referee"
14+
ON "contribution_submission" ("actionId", (("flags" ->> 'refereeId')))
15+
WHERE ("flags" ->> 'refereeId') IS NOT NULL
16+
`);
17+
}
18+
19+
public async down(queryRunner: QueryRunner): Promise<void> {
20+
await queryRunner.query(/* sql */ `
21+
DROP INDEX IF EXISTS "UQ_contribution_submission_referee"
22+
`);
23+
}
24+
}

src/workers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import userCreatedPersonalizedDigestSendType from './userCreatedPersonalizedDige
4646
import commentDownvotedRep from './commentDownvotedRep';
4747
import commentDownvoteCanceledRep from './commentDownvoteCanceledRep';
4848
import userUpdatedCio from './userUpdatedCio';
49+
import userActivatedContributionReferral from './userActivatedContributionReferral';
4950
import userDeletedCio from './userDeletedCio';
5051
import userStreakUpdatedCio from './userStreakUpdatedCio';
5152
import { vordrPostCommentPrevented } from './vordrPostCommentPrevented';
@@ -138,6 +139,7 @@ export const typedWorkers: BaseTypedWorker<any>[] = [
138139
postDownvotedRep,
139140
postDownvoteCanceledRep,
140141
userUpdatedCio,
142+
userActivatedContributionReferral,
141143
userDeletedCio,
142144
userStreakUpdatedCio,
143145
vordrPostCommentPrevented,

0 commit comments

Comments
 (0)