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: 0 additions & 4 deletions .infra/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
145 changes: 144 additions & 1 deletion __tests__/contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: {} },
Expand Down
12 changes: 7 additions & 5 deletions src/common/contribution/founding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
131 changes: 100 additions & 31 deletions src/schema/contributions.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<GQLContributionFoundingAward> => {
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,
Expand Down Expand Up @@ -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!
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -606,32 +657,10 @@ export const resolvers: IResolvers<unknown, BaseContext> = {
_,
__,
ctx: Context,
): Promise<GQLContributionFoundingAward> => {
): Promise<GQLContributionFoundingAward> =>
// 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,
Expand Down Expand Up @@ -1172,6 +1201,46 @@ export const resolvers: IResolvers<unknown, BaseContext> = {

return toGQLReward({ reward, tier });
},
claimContributionFoundingAward: async (
_,
__,
ctx: AuthContext,
): Promise<GQLContributionFoundingAward> => {
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: {
Expand Down
14 changes: 0 additions & 14 deletions src/workers/contributionFoundingAward.ts

This file was deleted.

Loading
Loading