Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .infra/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,10 @@ 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',
Expand Down
137 changes: 137 additions & 0 deletions __tests__/contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ import {
CONTRIBUTION_LAST_MILESTONE_REDIS_KEY,
detectContributionMilestones,
} from '../src/common/contribution';
import { 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';
import { deleteRedisKey } from '../src/redis';

let con: DataSource;
Expand All @@ -68,6 +72,7 @@ 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 foundingProductId = '77777777-7777-4777-8777-777777777771';

const CONTRIBUTION_STATUS_QUERY = `
query ContributionStatus {
Expand Down Expand Up @@ -327,9 +332,17 @@ beforeEach(async () => {
.from(ContributionActionCategory)
.execute();
await con.createQueryBuilder().delete().from(ContributionMilestone).execute();
await con
.createQueryBuilder()
.delete()
.from(ContributionFoundingContributor)
.execute();
await deleteRedisKey(CONTRIBUTION_LAST_MILESTONE_REDIS_KEY);
await con.getRepository(User).delete({ id: userId });
await con.getRepository(User).delete({ id: blockedUserId });
// 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 });

remoteConfig.vars.contributionProgram = {
enabled: true,
Expand Down Expand Up @@ -934,3 +947,127 @@ it('stamps crossed milestones once and exposes the highest reached', async () =>
title: '500',
});
});

const seedFoundingProduct = () =>
saveFixtures(con, Product, [
{
id: foundingProductId,
name: 'Founding contributor',
image: 'https://daily.dev/founding.jpg',
type: ProductType.Award,
value: 30,
},
]);

const mockNjord = () =>
jest
.spyOn(njordCommon, 'getNjordClient')
.mockImplementation(() =>
createClient(Credits, createMockNjordTransport()),
);

it('grants the founding contributor award, paid by the system', async () => {
mockNjord();
await seedFoundingProduct();

const granted = await grantFoundingContributorAward({
con,
userId,
productId: foundingProductId,
});

expect(granted).toBe(true);
await expect(
con
.getRepository(ContributionFoundingContributor)
.findOneByOrFail({ userId }),
).resolves.toMatchObject({ userId });
await expect(
con.getRepository(UserTransaction).findOneByOrFail({
receiverId: userId,
productId: foundingProductId,
}),
).resolves.toMatchObject({
senderId: systemUser.id,
processor: UserTransactionProcessor.Njord,
status: UserTransactionStatus.Success,
value: 30,
valueIncFees: 30,
fee: 0,
referenceType: UserTransactionType.User,
});
});

it('is idempotent per contributor', async () => {
mockNjord();
await seedFoundingProduct();

expect(
await grantFoundingContributorAward({
con,
userId,
productId: foundingProductId,
}),
).toBe(true);
expect(
await grantFoundingContributorAward({
con,
userId,
productId: foundingProductId,
}),
).toBe(false);

expect(
await con
.getRepository(UserTransaction)
.countBy({ receiverId: userId, productId: foundingProductId }),
).toBe(1);
});

it('stops granting once the cap is reached', async () => {
mockNjord();
await seedFoundingProduct();

expect(
await grantFoundingContributorAward({
con,
userId,
productId: foundingProductId,
limit: 1,
}),
).toBe(true);
expect(
await grantFoundingContributorAward({
con,
userId: blockedUserId,
productId: foundingProductId,
limit: 1,
}),
).toBe(false);

expect(await con.getRepository(ContributionFoundingContributor).count()).toBe(
1,
);
await expect(
con
.getRepository(UserTransaction)
.findOneBy({ receiverId: blockedUserId, productId: foundingProductId }),
).resolves.toBeNull();
});

it('is a no-op when the award product is unset or missing', async () => {
mockNjord();

expect(await grantFoundingContributorAward({ con, userId })).toBe(false);
expect(
await grantFoundingContributorAward({
con,
userId,
productId: foundingProductId,
}),
).toBe(false);

expect(await con.getRepository(ContributionFoundingContributor).count()).toBe(
0,
);
});
95 changes: 95 additions & 0 deletions src/common/contribution/founding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { randomUUID } from 'crypto';
import type { DataSource } from 'typeorm';
import { Product, ProductType } from '../../entity/Product';
import { ContributionFoundingContributor } from '../../entity/contribution/ContributionFoundingContributor';
import {
UserTransaction,
UserTransactionProcessor,
UserTransactionStatus,
UserTransactionType,
} from '../../entity/user/UserTransaction';
import { transferCores } from '../njord';
import { systemUser } from '../utils';

export const CONTRIBUTION_FOUNDING_LIMIT = 1000;

// 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).
export const grantFoundingContributorAward = async ({
con,
userId,
limit = CONTRIBUTION_FOUNDING_LIMIT,
// Award (Product) granted to the first contributors. Unset until the dedicated
// product exists, in which case the grant is a no-op.
productId = process.env.CONTRIBUTION_FOUNDING_AWARD_PRODUCT_ID,
}: {
con: DataSource;
userId: string;
limit?: number;
productId?: string;
}): Promise<boolean> => {
if (!productId) {
return false;
}

return con.transaction(async (manager) => {
const foundingRepo = manager.getRepository(ContributionFoundingContributor);

if ((await foundingRepo.count()) >= limit) {
return false;
}

const product = await manager.getRepository(Product).findOne({
select: ['id', 'value'],
where: { id: productId, type: ProductType.Award },
});
if (!product) {
return false;
}

const transactionId = randomUUID();
// RETURNING reflects ON CONFLICT DO NOTHING: empty when the user is already a
// founding contributor. `identifiers` is unreliable here because the PK is
// provided (not generated), so it echoes the input even on a no-op insert.
const reservation = await foundingRepo
.createQueryBuilder()
.insert()
.values({ userId, transactionId })
.orIgnore()
.returning(['userId'])
.execute();

if (!reservation.raw.length) {
return false;
}

const transaction = await manager.getRepository(UserTransaction).save(
manager.getRepository(UserTransaction).create({
id: transactionId,
processor: UserTransactionProcessor.Njord,
receiverId: userId,
status: UserTransactionStatus.Success,
productId: product.id,
senderId: systemUser.id,
value: product.value,
valueIncFees: product.value,
fee: 0,
request: {},
flags: { note: 'Founding contributor award' },
referenceId: userId,
referenceType: UserTransactionType.User,
}),
);

await transferCores({
ctx: { userId },
transaction,
entityManager: manager,
});

return true;
});
};
29 changes: 29 additions & 0 deletions src/entity/contribution/ContributionFoundingContributor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
import type { User } from '../user/User';

// The first N contributors, awarded the founding-contributor award. Presence is
// the idempotency marker and the row count enforces the cap.
@Entity()
export class ContributionFoundingContributor {
@PrimaryColumn({
length: 36,
primaryKeyConstraintName: 'PK_contribution_founding_contributor',
})
userId: string;

@Column({ default: () => 'now()' })
createdAt: Date;

@Column({ type: 'uuid', nullable: true, default: null })
transactionId: string | null;

@ManyToOne('User', {
lazy: true,
onDelete: 'CASCADE',
})
@JoinColumn({
name: 'userId',
foreignKeyConstraintName: 'FK_contribution_founding_contributor_user_id',
})
user: Promise<User>;
}
1 change: 1 addition & 0 deletions src/entity/contribution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from './ContributionActionCategory';
export * from './ContributionActionLink';
export * from './ContributionBlockedUser';
export * from './ContributionCause';
export * from './ContributionFoundingContributor';
export * from './ContributionMilestone';
export * from './ContributionPayment';
export * from './ContributionPaymentAllocation';
Expand Down
34 changes: 34 additions & 0 deletions src/migration/1782990376799-AddContributionFoundingContributor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddContributionFoundingContributor1782990376799
implements MigrationInterface
{
name = 'AddContributionFoundingContributor1782990376799';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "contribution_founding_contributor" (
"userId" character varying(36) NOT NULL,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"transactionId" uuid,
CONSTRAINT "PK_contribution_founding_contributor" PRIMARY KEY ("userId")
)`,
);
await queryRunner.query(
`ALTER TABLE "contribution_founding_contributor"
ADD CONSTRAINT "FK_contribution_founding_contributor_user_id"
FOREIGN KEY ("userId") REFERENCES "user"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "contribution_founding_contributor"
DROP CONSTRAINT IF EXISTS "FK_contribution_founding_contributor_user_id"`,
);
await queryRunner.query(
`DROP TABLE IF EXISTS "contribution_founding_contributor"`,
);
}
}
14 changes: 14 additions & 0 deletions src/workers/contributionFoundingAward.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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<void> => {
await grantFoundingContributorAward({
con,
userId: data.submission.userId,
});
},
};

export default worker;
2 changes: 2 additions & 0 deletions src/workers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ 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';
Expand Down Expand Up @@ -182,6 +183,7 @@ export const typedWorkers: BaseTypedWorker<any>[] = [
newHighlightRealTime,
contributionActionCompletedRealTime,
contributionActionCompletedSlack,
contributionFoundingAward,
contributionMilestoneReached,
userDeletionCleanup,
liveRoomStartedWorker,
Expand Down
Loading