diff --git a/.infra/common.ts b/.infra/common.ts index 6d64ba848f..448d8a7d01 100644 --- a/.infra/common.ts +++ b/.infra/common.ts @@ -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', diff --git a/__tests__/contributions.ts b/__tests__/contributions.ts index c7bd0c41f9..17589893af 100644 --- a/__tests__/contributions.ts +++ b/__tests__/contributions.ts @@ -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; @@ -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 { @@ -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, @@ -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, + ); +}); diff --git a/src/common/contribution/founding.ts b/src/common/contribution/founding.ts new file mode 100644 index 0000000000..96d1104aff --- /dev/null +++ b/src/common/contribution/founding.ts @@ -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 => { + 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; + }); +}; diff --git a/src/entity/contribution/ContributionFoundingContributor.ts b/src/entity/contribution/ContributionFoundingContributor.ts new file mode 100644 index 0000000000..47d23f58f0 --- /dev/null +++ b/src/entity/contribution/ContributionFoundingContributor.ts @@ -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; +} diff --git a/src/entity/contribution/index.ts b/src/entity/contribution/index.ts index ac4ab4cc19..501dd151e0 100644 --- a/src/entity/contribution/index.ts +++ b/src/entity/contribution/index.ts @@ -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'; diff --git a/src/migration/1782990376799-AddContributionFoundingContributor.ts b/src/migration/1782990376799-AddContributionFoundingContributor.ts new file mode 100644 index 0000000000..b18602a6d9 --- /dev/null +++ b/src/migration/1782990376799-AddContributionFoundingContributor.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddContributionFoundingContributor1782990376799 + implements MigrationInterface +{ + name = 'AddContributionFoundingContributor1782990376799'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`, + ); + } +} diff --git a/src/workers/contributionFoundingAward.ts b/src/workers/contributionFoundingAward.ts new file mode 100644 index 0000000000..c9840cdf28 --- /dev/null +++ b/src/workers/contributionFoundingAward.ts @@ -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 => { + await grantFoundingContributorAward({ + con, + userId: data.submission.userId, + }); + }, +}; + +export default worker; diff --git a/src/workers/index.ts b/src/workers/index.ts index 63768e9fc7..1c25b3d5c2 100644 --- a/src/workers/index.ts +++ b/src/workers/index.ts @@ -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'; @@ -182,6 +183,7 @@ export const typedWorkers: BaseTypedWorker[] = [ newHighlightRealTime, contributionActionCompletedRealTime, contributionActionCompletedSlack, + contributionFoundingAward, contributionMilestoneReached, userDeletionCleanup, liveRoomStartedWorker,