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
192 changes: 192 additions & 0 deletions __tests__/contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
]);
});
144 changes: 143 additions & 1 deletion src/common/contribution/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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<unknown> =>
Expand Down
21 changes: 21 additions & 0 deletions src/graphorm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading
Loading