From bc9919b729fd84103ff62ce4112825e721312f3c Mon Sep 17 00:00:00 2001 From: Brian Turcotte Date: Thu, 7 May 2026 15:47:03 -0400 Subject: [PATCH 1/2] feat(contributors): add manual tier upgrade for enrolled champions --- apps/web/src/app/admin/contributors/page.tsx | 183 +++++++++++++++++- .../src/lib/contributor-champions/service.ts | 77 ++++++++ .../admin/contributor-champions-router.ts | 21 ++ 3 files changed, 279 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/admin/contributors/page.tsx b/apps/web/src/app/admin/contributors/page.tsx index 3055c73939..512e262cd2 100644 --- a/apps/web/src/app/admin/contributors/page.tsx +++ b/apps/web/src/app/admin/contributors/page.tsx @@ -37,6 +37,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { SortableButton } from '../components/SortableButton'; import { AlertCircle, + ArrowUpCircle, ChevronLeft, Check, ChevronRight, @@ -78,6 +79,13 @@ type EnrollmentState = { tier: ContributorTier; }; +type UpgradeState = { + contributorId: string; + githubLogin: string; + currentTier: ContributorTier; + newTier: ContributorTier; +}; + type SortConfig = { field: T; direction: 'asc' | 'desc'; @@ -151,6 +159,16 @@ function normalizeTier(value: string): ContributorTier | null { return null; } +const TIER_ORDER: Record = { + contributor: 0, + ambassador: 1, + champion: 2, +}; + +function higherTiersFor(current: ContributorTier): ContributorTier[] { + return contributorTiers.filter(t => TIER_ORDER[t] > TIER_ORDER[current]); +} + function TierDisplay({ tier }: { tier: ContributorTier | null }) { if (!tier) { return ; @@ -325,6 +343,9 @@ export default function ContributorChampionsAdminPage() { const [drillInState, setDrillInState] = useState(null); const [enrollmentState, setEnrollmentState] = useState(null); + const [upgradeState, setUpgradeState] = useState(null); + // Per-row selected upgrade tier, keyed by contributorId + const [upgradeSelections, setUpgradeSelections] = useState>({}); // Enrolled table state const [enrolledPage, setEnrolledPage] = useState(1); const [enrolledFilters, setEnrolledFilters] = useState({ @@ -424,6 +445,26 @@ export default function ContributorChampionsAdminPage() { }) ); + const upgradeMutation = useMutation( + trpc.admin.contributorChampions.upgradeTier.mutationOptions({ + onSuccess: result => { + const creditMsg = + result.creditDifferentialUsd > 0 + ? result.creditGranted + ? ` — $${result.creditDifferentialUsd} top-up credit granted` + : ` — credit pending (no linked account)` + : ''; + toast.success(`Upgraded to ${result.upgradedTier}${creditMsg}`); + setUpgradeState(null); + setUpgradeSelections({}); + refreshContributorQueries(); + }, + onError: (error: { message: string }) => { + toast.error(`Failed to upgrade tier: ${error.message}`); + }, + }) + ); + const syncMutation = useMutation( trpc.admin.contributorChampions.syncNow.mutationOptions({ onSuccess: () => { @@ -700,18 +741,19 @@ export default function ContributorChampionsAdminPage() { Credits/mo Last Grant GH Integration + Upgrade {isLoadingTables ? ( - + ) : enrolledPageRows.length === 0 ? ( - + No enrolled contributors. @@ -778,6 +820,63 @@ export default function ContributorChampionsAdminPage() { )} + + {row.enrolledTier && higherTiersFor(row.enrolledTier).length > 0 ? ( +
+ + +
+ ) : ( + + )} +
)) )} @@ -1195,6 +1294,86 @@ export default function ContributorChampionsAdminPage() { + { + if (!open) { + if (upgradeState) { + setUpgradeSelections(prev => { + const next = { ...prev }; + delete next[upgradeState.contributorId]; + return next; + }); + } + setUpgradeState(null); + } + }} + > + + + Confirm tier upgrade + + Upgrade @{upgradeState?.githubLogin} from {upgradeState?.currentTier} to{' '} + {upgradeState?.newTier}. + + + + {upgradeState ? ( +
+

+ Immediate top-up:{' '} + + $ + {TIER_CREDIT_USD[upgradeState.newTier] - + TIER_CREDIT_USD[upgradeState.currentTier]}{' '} + in Kilo Credits + {' '} + (the difference between {upgradeState.currentTier} and {upgradeState.newTier} for + the current period). +

+

+ Going forward: ${TIER_CREDIT_USD[upgradeState.newTier]}/month at the next + renewal. +

+ {(() => { + const matchedRow = (enrolledQuery.data ?? []).find( + r => r.contributorId === upgradeState.contributorId + ); + if (!matchedRow?.linkedUserId) { + return ( +

+ ⚠️ No linked Kilo account found. The top-up credit cannot be granted until the + contributor has a Kilo account with a matching email. +

+ ); + } + return null; + })()} +
+ ) : null} + + + + + + + +
+
+ {/* Manual Enrollment Dialog */} { + const leaderboard = await getContributorChampionLeaderboard(); + const row = leaderboard.find(value => value.contributorId === input.contributorId); + if (!row) throw new Error('Contributor not found'); + if (!row.enrolledTier) throw new Error('Contributor is not enrolled'); + + const currentCreditUsd = TIER_CREDIT_USD[row.enrolledTier]; + const newCreditUsd = TIER_CREDIT_USD[input.newTier]; + + if (newCreditUsd <= currentCreditUsd) { + throw new Error( + `New tier "${input.newTier}" must be higher than current tier "${row.enrolledTier}"` + ); + } + + const creditDifferentialUsd = newCreditUsd - currentCreditUsd; + const newCreditAmountMicrodollars = toMicrodollars(newCreditUsd); + // Prefer the explicit membership link over the email-derived match, same as enrollContributorChampion. + const linkedKiloUserId = row.linkedKiloUserId ?? row.linkedUserId; + + const creditGranted = await db.transaction(async tx => { + // Lock the contributor row to serialize concurrent upgrade requests. + await tx.execute( + sql`SELECT id FROM contributor_champion_contributors WHERE id = ${input.contributorId} FOR UPDATE` + ); + + // Update the tier and monthly credit amount; preserve credits_last_granted_at so + // the existing renewal cycle continues uninterrupted at the new (higher) amount. + await tx + .update(contributor_champion_memberships) + .set({ + enrolled_tier: input.newTier, + credit_amount_microdollars: newCreditAmountMicrodollars, + updated_at: sql`now()`, + }) + .where(eq(contributor_champion_memberships.contributor_id, input.contributorId)); + + // Grant the credit differential immediately (the top-up for the current period). + let granted = false; + if (creditDifferentialUsd > 0 && linkedKiloUserId) { + const [linkedUser] = await tx + .select() + .from(kilocode_users) + .where(eq(kilocode_users.id, linkedKiloUserId)) + .limit(1); + + if (linkedUser) { + const result = await grantCreditForCategory(linkedUser, { + credit_category: 'contributor-champion-credits', + amount_usd: creditDifferentialUsd, + expiry_hours: CREDIT_EXPIRY_HOURS, + counts_as_selfservice: false, + dbOrTx: tx, + }); + granted = result.success; + } + } + + return granted; + }); + + return { + upgradedTier: input.newTier, + creditDifferentialUsd, + creditGranted, + }; +} + export async function getEnrolledContributorChampions(): Promise { const leaderboard = await getContributorChampionLeaderboard(); return leaderboard.filter(row => row.enrolledTier !== null || row.enrolledAt !== null); diff --git a/apps/web/src/routers/admin/contributor-champions-router.ts b/apps/web/src/routers/admin/contributor-champions-router.ts index 3d48d5f9a4..40456036ce 100644 --- a/apps/web/src/routers/admin/contributor-champions-router.ts +++ b/apps/web/src/routers/admin/contributor-champions-router.ts @@ -8,6 +8,7 @@ import { manualEnrollContributor, searchKiloUsersByEmail, syncContributorChampionData, + upgradeContributorChampionTier, upsertContributorSelectedTier, } from '@/lib/contributor-champions/service'; import * as z from 'zod'; @@ -73,6 +74,26 @@ export const contributorChampionsRouter = createTRPCRouter({ }; }), + upgradeTier: adminProcedure + .input( + z.object({ + contributorId: z.string().uuid(), + newTier: TierSchema, + }) + ) + .mutation(async ({ input }) => { + const result = await upgradeContributorChampionTier({ + contributorId: input.contributorId, + newTier: input.newTier, + }); + return { + success: true, + upgradedTier: result.upgradedTier, + creditDifferentialUsd: result.creditDifferentialUsd, + creditGranted: result.creditGranted, + }; + }), + enrolledList: adminProcedure.query(async () => { return getEnrolledContributorChampions(); }), From 34cd96cebe0c266157066f83b8216e959017a93c Mon Sep 17 00:00:00 2001 From: Brian Turcotte Date: Thu, 7 May 2026 17:41:15 -0400 Subject: [PATCH 2/2] fix(contributors): close stale-read race and missing renewal-clock reset on tier upgrade --- .../src/lib/contributor-champions/service.ts | 52 +++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/apps/web/src/lib/contributor-champions/service.ts b/apps/web/src/lib/contributor-champions/service.ts index 3695e51d00..1472d22821 100644 --- a/apps/web/src/lib/contributor-champions/service.ts +++ b/apps/web/src/lib/contributor-champions/service.ts @@ -803,28 +803,50 @@ export async function upgradeContributorChampionTier(input: { if (!row) throw new Error('Contributor not found'); if (!row.enrolledTier) throw new Error('Contributor is not enrolled'); - const currentCreditUsd = TIER_CREDIT_USD[row.enrolledTier]; + // Coarse pre-check using the leaderboard snapshot. The authoritative check + // happens inside the transaction after the row lock is acquired. const newCreditUsd = TIER_CREDIT_USD[input.newTier]; - - if (newCreditUsd <= currentCreditUsd) { + if (newCreditUsd <= TIER_CREDIT_USD[row.enrolledTier]) { throw new Error( `New tier "${input.newTier}" must be higher than current tier "${row.enrolledTier}"` ); } - const creditDifferentialUsd = newCreditUsd - currentCreditUsd; const newCreditAmountMicrodollars = toMicrodollars(newCreditUsd); // Prefer the explicit membership link over the email-derived match, same as enrollContributorChampion. const linkedKiloUserId = row.linkedKiloUserId ?? row.linkedUserId; - const creditGranted = await db.transaction(async tx => { + const { creditGranted, creditDifferentialUsd } = await db.transaction(async tx => { // Lock the contributor row to serialize concurrent upgrade requests. await tx.execute( sql`SELECT id FROM contributor_champion_contributors WHERE id = ${input.contributorId} FOR UPDATE` ); - // Update the tier and monthly credit amount; preserve credits_last_granted_at so - // the existing renewal cycle continues uninterrupted at the new (higher) amount. + // Re-read the membership inside the transaction after acquiring the lock so the + // differential is computed from the authoritative current tier, not the + // leaderboard snapshot taken before the lock. Without this, two concurrent + // upgrade calls could both read the pre-upgrade tier, both compute the same + // differential, and both grant — double-granting the top-up. + const [membership] = await tx + .select({ enrolled_tier: contributor_champion_memberships.enrolled_tier }) + .from(contributor_champion_memberships) + .where(eq(contributor_champion_memberships.contributor_id, input.contributorId)) + .limit(1); + + if (!membership?.enrolled_tier) throw new Error('Contributor membership not found'); + + const lockedCurrentCreditUsd = + TIER_CREDIT_USD[membership.enrolled_tier as ContributorTier] ?? 0; + const lockedDifferentialUsd = newCreditUsd - lockedCurrentCreditUsd; + + if (lockedDifferentialUsd <= 0) { + throw new Error( + `Tier is already at or above "${input.newTier}" (current: "${membership.enrolled_tier}")` + ); + } + + const now = new Date().toISOString(); + await tx .update(contributor_champion_memberships) .set({ @@ -836,7 +858,7 @@ export async function upgradeContributorChampionTier(input: { // Grant the credit differential immediately (the top-up for the current period). let granted = false; - if (creditDifferentialUsd > 0 && linkedKiloUserId) { + if (lockedDifferentialUsd > 0 && linkedKiloUserId) { const [linkedUser] = await tx .select() .from(kilocode_users) @@ -846,7 +868,7 @@ export async function upgradeContributorChampionTier(input: { if (linkedUser) { const result = await grantCreditForCategory(linkedUser, { credit_category: 'contributor-champion-credits', - amount_usd: creditDifferentialUsd, + amount_usd: lockedDifferentialUsd, expiry_hours: CREDIT_EXPIRY_HOURS, counts_as_selfservice: false, dbOrTx: tx, @@ -855,7 +877,17 @@ export async function upgradeContributorChampionTier(input: { } } - return granted; + // Reset the renewal clock after a successful top-up grant. Without this, + // refreshContributorChampionCredits could see a stale credits_last_granted_at + // and immediately grant the full new monthly amount on top of the top-up. + if (granted) { + await tx + .update(contributor_champion_memberships) + .set({ credits_last_granted_at: now }) + .where(eq(contributor_champion_memberships.contributor_id, input.contributorId)); + } + + return { creditGranted: granted, creditDifferentialUsd: lockedDifferentialUsd }; }); return {