diff --git a/__tests__/contributions.ts b/__tests__/contributions.ts index 17589893af..da7113e6f1 100644 --- a/__tests__/contributions.ts +++ b/__tests__/contributions.ts @@ -194,6 +194,43 @@ query ContributionLastReachedMilestone { } `; +const CONTRIBUTION_LEADERBOARD_QUERY = ` +query ContributionLeaderboard($first: Int) { + contributionLeaderboard(first: $first) { + edges { + node { + user { + id + } + points + rank + } + } + pageInfo { + hasNextPage + } + } +} +`; + +const CONTRIBUTION_USER_RANK_QUERY = ` +query ContributionUserRank { + contributionUserRank { + points + rank + } +} +`; + +const CONTRIBUTION_CAUSE_BREAKDOWN_QUERY = ` +query ContributionCauseBreakdown { + contributionCauseBreakdown { + category + points + } +} +`; + const CONTRIBUTION_PREFERENCES_QUERY = ` query ContributionCausePreferences { contributionCausePreferences(first: 10) { @@ -1071,3 +1108,158 @@ it('is a no-op when the award product is unset or missing', async () => { 0, ); }); + +const seedLeaderboardAction = () => + saveFixtures(con, ContributionAction, [ + { id: actionId, title: 'Post', points: 10, evidence: {} }, + ]); + +it('returns the current-cycle leaderboard ranked by unpaid points', async () => { + await seedLeaderboardAction(); + await saveFixtures(con, ContributionPayment, [ + { + id: paymentId, + status: ContributionPaymentStatus.Finalized, + totalPoints: 500, + amountCents: 1000, + finalizedAt: new Date(), + }, + ]); + await saveFixtures(con, ContributionSubmission, [ + { + userId, + actionId, + status: ContributionSubmissionStatus.Approved, + awardedPoints: 30, + }, + { + userId, + actionId, + status: ContributionSubmissionStatus.Approved, + awardedPoints: 40, + }, + { + userId: blockedUserId, + actionId, + status: ContributionSubmissionStatus.Approved, + awardedPoints: 100, + }, + // Paid submission belongs to a past cycle and must be excluded. + { + userId, + actionId, + status: ContributionSubmissionStatus.Approved, + awardedPoints: 500, + paymentId, + }, + ]); + + const res = await client.query(CONTRIBUTION_LEADERBOARD_QUERY, { + variables: { first: 10 }, + }); + + expect(res.errors).toBeUndefined(); + expect( + res.data.contributionLeaderboard.edges.map((edge) => edge.node), + ).toEqual([ + { user: { id: blockedUserId }, points: 100, rank: 1 }, + { user: { id: userId }, points: 70, rank: 2 }, + ]); +}); + +it('returns the viewer rank, null when they have no current-cycle points', async () => { + await seedLeaderboardAction(); + await saveFixtures(con, ContributionSubmission, [ + { + userId: blockedUserId, + actionId, + status: ContributionSubmissionStatus.Approved, + awardedPoints: 100, + }, + ]); + + loggedUser = userId; + const noRank = await client.query(CONTRIBUTION_USER_RANK_QUERY); + expect(noRank.data.contributionUserRank).toBeNull(); + + loggedUser = blockedUserId; + const ranked = await client.query(CONTRIBUTION_USER_RANK_QUERY); + expect(ranked.data.contributionUserRank).toEqual({ points: 100, rank: 1 }); +}); + +it('projects the current-cycle cause breakdown across categories', async () => { + const thirdCauseId = '33333333-3333-4333-8333-333333333336'; + await seedLeaderboardAction(); + await saveFixtures(con, ContributionCause, [ + { id: causeId, title: 'Cause A', category: 'Education', sortOrder: 1 }, + { + id: secondCauseId, + title: 'Cause B', + category: 'Open source', + sortOrder: 2, + }, + { id: thirdCauseId, title: 'Cause C', category: 'Education', sortOrder: 3 }, + ]); + await saveFixtures(con, UserContributionCausePreference, [ + { userId, causeId }, + { userId, causeId: secondCauseId }, + ]); + await saveFixtures(con, ContributionSubmission, [ + { + userId, + actionId, + status: ContributionSubmissionStatus.Approved, + awardedPoints: 100, + }, + // No preference: splits equally across all active causes. + { + userId: blockedUserId, + actionId, + status: ContributionSubmissionStatus.Approved, + awardedPoints: 60, + }, + ]); + + const res = await client.query(CONTRIBUTION_CAUSE_BREAKDOWN_QUERY); + + expect(res.errors).toBeUndefined(); + expect(res.data.contributionCauseBreakdown).toEqual([ + { category: 'Education', points: 90 }, + { category: 'Open source', points: 70 }, + ]); +}); + +it('rounds fractional cause shares while preserving the total', async () => { + const catAId = '33333333-3333-4333-8333-333333333341'; + const catBId = '33333333-3333-4333-8333-333333333342'; + const catCId = '33333333-3333-4333-8333-333333333343'; + await seedLeaderboardAction(); + await saveFixtures(con, ContributionCause, [ + { id: catAId, title: 'A', category: 'Cat A', sortOrder: 1 }, + { id: catBId, title: 'B', category: 'Cat B', sortOrder: 2 }, + { id: catCId, title: 'C', category: 'Cat C', sortOrder: 3 }, + ]); + await saveFixtures(con, UserContributionCausePreference, [ + { userId, causeId: catAId }, + { userId, causeId: catBId }, + { userId, causeId: catCId }, + ]); + await saveFixtures(con, ContributionSubmission, [ + { + userId, + actionId, + status: ContributionSubmissionStatus.Approved, + awardedPoints: 10, + }, + ]); + + const res = await client.query(CONTRIBUTION_CAUSE_BREAKDOWN_QUERY); + + expect(res.errors).toBeUndefined(); + const points = res.data.contributionCauseBreakdown.map((row) => row.points); + // 10 split across 3 categories -> 4/3/3 via largest remainder, summing to 10. + expect(points.reduce((sum, value) => sum + value, 0)).toBe(10); + expect([...points].sort((first, second) => second - first)).toEqual([ + 4, 3, 3, + ]); +}); diff --git a/src/common/contribution/index.ts b/src/common/contribution/index.ts index 4bf0737744..96b90996c9 100644 --- a/src/common/contribution/index.ts +++ b/src/common/contribution/index.ts @@ -1,5 +1,5 @@ import { ForbiddenError, ValidationError } from 'apollo-server-errors'; -import { In, type EntityManager } from 'typeorm'; +import { In, type DataSource, type EntityManager } from 'typeorm'; import type z from 'zod'; import { contributionActionEvidenceSchema, @@ -23,6 +23,7 @@ import { UserContributionCausePreference } from '../../entity/contribution/UserC import { remoteConfig } from '../../remoteConfig'; import { ONE_HOUR_IN_SECONDS } from '../constants'; import { getRedisObject, setRedisObjectWithExpiry } from '../../redis'; +import { queryReadReplica } from '../queryReadReplica'; export const CONTRIBUTION_ACTION_COMPLETED_CHANNEL = 'events.contributions.completed'; @@ -528,6 +529,147 @@ export const getLifetimeAmountCents = async ({ return toContributionInt(row?.sum); }; +// The viewer's standing in the current cycle (unpaid approved points). Null when +// they have no current-cycle points and therefore no rank. +export const getContributionUserRank = async ({ + con, + userId, +}: { + con: DataSource; + userId: string; +}): Promise<{ points: number; rank: number } | null> => { + const row = await queryReadReplica(con, ({ queryRunner }) => + queryRunner.manager + .createQueryBuilder() + .addCommonTableExpression( + ` + SELECT + "userId", + SUM("awardedPoints")::int AS points, + ROW_NUMBER() OVER ( + ORDER BY COALESCE(SUM("awardedPoints"), 0) DESC, + MIN("createdAt") ASC, + "userId" ASC + ) AS rank + FROM "contribution_submission" + WHERE status = :status + AND "paymentId" IS NULL + GROUP BY "userId" + `, + 'ranked', + ) + .select('ranked.points', 'points') + .addSelect('ranked.rank', 'rank') + .from('ranked', 'ranked') + .where('ranked."userId" = :userId', { userId }) + .setParameter('status', ContributionSubmissionStatus.Approved) + .getRawOne<{ points: string | number; rank: string | number }>(), + ); + + if (!row) { + return null; + } + + return { + points: toContributionInt(row.points), + rank: toContributionInt(row.rank), + }; +}; + +// Projects how the current cycle's unpaid points would be allocated across cause +// categories: each contributor's points split equally among their preferred +// active causes (falling back to all active causes), mirroring payout allocation +// so the shares sum to the whole. +export const getContributionCauseBreakdown = async ({ + con, +}: { + con: DataSource; +}): Promise<{ category: string | null; points: number }[]> => { + const rows = await queryReadReplica(con, ({ queryRunner }) => + queryRunner.manager + .createQueryBuilder() + .addCommonTableExpression( + `SELECT id, category FROM "contribution_cause" WHERE active = true`, + 'active_cause', + ) + .addCommonTableExpression( + ` + SELECT + "userId", + SUM("awardedPoints")::float AS points + FROM "contribution_submission" + WHERE status = :status + AND "paymentId" IS NULL + GROUP BY "userId" + `, + 'user_points', + ) + .addCommonTableExpression( + ` + SELECT + up."userId", + up.points, + COALESCE( + ( + SELECT array_agg(preference."causeId") + FROM "user_contribution_cause_preference" preference + INNER JOIN active_cause ON active_cause.id = preference."causeId" + WHERE preference."userId" = up."userId" + ), + (SELECT array_agg(id) FROM active_cause) + ) AS "causeIds" + FROM user_points up + `, + 'user_cause', + ) + .addCommonTableExpression( + ` + SELECT + unnest(user_cause."causeIds") AS "causeId", + user_cause.points / array_length(user_cause."causeIds", 1) AS points + FROM user_cause + WHERE array_length(user_cause."causeIds", 1) > 0 + `, + 'split', + ) + .select('active_cause.category', 'category') + .addSelect('SUM(split.points)', 'points') + .from('split', 'split') + .innerJoin( + 'active_cause', + 'active_cause', + 'active_cause.id = split."causeId"', + ) + .groupBy('active_cause.category') + .orderBy('points', 'DESC') + .addOrderBy('active_cause.category', 'ASC') + .setParameter('status', ContributionSubmissionStatus.Approved) + .getRawMany<{ category: string | null; points: string | number }>(), + ); + + // Round the fractional category shares to integers while preserving the total + // (largest-remainder method), so the parts still sum to the whole. + const shares = rows.map((row) => ({ + category: row.category, + exact: Number(row.points ?? 0), + })); + const total = Math.round(shares.reduce((sum, share) => sum + share.exact, 0)); + const result = shares.map((share) => ({ + category: share.category, + points: Math.floor(share.exact), + remainder: share.exact - Math.floor(share.exact), + })); + const leftover = total - result.reduce((sum, share) => sum + share.points, 0); + [...result] + .sort((first, second) => second.remainder - first.remainder) + .slice(0, Math.max(0, leftover)) + .forEach((share) => { + share.points += 1; + }); + + return result.map(({ category, points }) => ({ category, points })); +}; + const cacheLastReachedMilestone = ( milestone: CachedContributionMilestone | null, ): Promise => diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index c2a3feeda6..f4befd0627 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -471,6 +471,27 @@ const obj = new GraphORM({ }, }, }, + ContributionLeaderboardEntry: { + from: 'ContributionSubmission', + fields: { + user: { + relation: { + isMany: false, + customRelation: (_, parentAlias, childAlias, qb): QueryBuilder => + qb.where(`"${childAlias}"."id" = "${parentAlias}"."userId"`), + }, + }, + points: { + select: (_, alias) => `COALESCE(SUM("${alias}"."awardedPoints"), 0)`, + transform: toContributionNumber, + }, + rank: { + select: (_, alias) => + `ROW_NUMBER() OVER (ORDER BY COALESCE(SUM("${alias}"."awardedPoints"), 0) DESC, MIN("${alias}"."createdAt") ASC, "${alias}"."userId" ASC)`, + transform: toContributionNumber, + }, + }, + }, User: { requiredColumns: ['id', 'username', 'createdAt'], fields: { diff --git a/src/schema/contributions.ts b/src/schema/contributions.ts index 3dbe615f48..0e9a3290a1 100644 --- a/src/schema/contributions.ts +++ b/src/schema/contributions.ts @@ -10,7 +10,9 @@ import { getApprovedContributorsCount, getApprovedPointsSum, getContributionConfig, + getContributionCauseBreakdown, getContributionEligibility, + getContributionUserRank, getLastReachedMilestone, getLifetimeAmountCents, parseContributionArgs, @@ -122,6 +124,11 @@ type GQLUserContributionCauseStats = { amountCents: number; }; +type GQLContributionLeaderboardEntry = { + points: number; + rank: number; +}; + type GQLContributionActionCompleted = { submissionId: string; userId: string; @@ -362,6 +369,35 @@ export const typeDefs = /* GraphQL */ ` edges: [ContributionSponsorEdge!]! } + type ContributionLeaderboardEntry { + user: User! + points: Int! + rank: Int! + } + + type ContributionLeaderboardEntryEdge { + node: ContributionLeaderboardEntry! + cursor: String! + } + + type ContributionLeaderboardConnection { + pageInfo: PageInfo! + edges: [ContributionLeaderboardEntryEdge!]! + } + + type ContributionUserRank { + points: Int! + rank: Int! + } + + """ + Projected share of the current cycle's points for one cause category. + """ + type ContributionCauseCategoryBreakdown { + category: String + points: Int! + } + input SubmitContributionActionInput { actionId: ID! evidence: JSON! @@ -423,6 +459,22 @@ export const typeDefs = /* GraphQL */ ` gift-icon poll. Null until the first milestone is crossed. """ contributionLastReachedMilestone: ContributionMilestone + """ + Current-cycle leaderboard ranked by unpaid approved points. + """ + contributionLeaderboard( + first: Int + after: String + ): ContributionLeaderboardConnection! + """ + The viewer's own current-cycle points and rank. Null when they have no + current-cycle points. + """ + contributionUserRank: ContributionUserRank @auth + """ + Projected current-cycle points split across cause categories. + """ + contributionCauseBreakdown: [ContributionCauseCategoryBreakdown!]! } extend type Mutation { @@ -816,6 +868,53 @@ export const resolvers: IResolvers = { ContributionMilestone, 'id' | 'value' | 'title' | 'reachedAt' > | null> => getLastReachedMilestone({ con: ctx.con.manager }), + contributionLeaderboard: async ( + _, + args: ConnectionArguments, + ctx: Context, + info: GraphQLResolveInfo, + ): Promise> => { + const parsedArgs = parseContributionArgs( + contributionConnectionArgsSchema, + args, + ); + + return queryContributionConnection({ + args: parsedArgs, + ctx, + info, + beforeQuery: (builder, page) => { + builder.queryBuilder + .where(`${builder.alias}.status = :status`, { + status: ContributionSubmissionStatus.Approved, + }) + .andWhere(`${builder.alias}."paymentId" IS NULL`) + .groupBy(`${builder.alias}."userId"`) + .orderBy( + `COALESCE(SUM(${builder.alias}."awardedPoints"), 0)`, + 'DESC', + ) + .addOrderBy(`MIN(${builder.alias}."createdAt")`, 'ASC') + .addOrderBy(`${builder.alias}."userId"`, 'ASC') + .limit(page.limit) + .offset(page.offset); + + return builder; + }, + }); + }, + contributionUserRank: ( + _, + __, + ctx: AuthContext, + ): Promise<{ points: number; rank: number } | null> => + getContributionUserRank({ con: ctx.con, userId: ctx.userId }), + contributionCauseBreakdown: ( + _, + __, + ctx: Context, + ): Promise<{ category: string | null; points: number }[]> => + getContributionCauseBreakdown({ con: ctx.con }), }, Mutation: { submitContributionAction: async (