Skip to content

Commit 0abd6e2

Browse files
authored
feat(contributors): add manual tier upgrade for enrolled champions (#3121)
* feat(contributors): add manual tier upgrade for enrolled champions * fix(contributors): close stale-read race and missing renewal-clock reset on tier upgrade * fix(contributors): address PR review comments on tier upgrade
1 parent 48352fe commit 0abd6e2

3 files changed

Lines changed: 317 additions & 2 deletions

File tree

apps/web/src/app/admin/contributors/page.tsx

Lines changed: 181 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
3737
import { SortableButton } from '../components/SortableButton';
3838
import {
3939
AlertCircle,
40+
ArrowUpCircle,
4041
ChevronLeft,
4142
Check,
4243
ChevronRight,
@@ -78,6 +79,13 @@ type EnrollmentState = {
7879
tier: ContributorTier;
7980
};
8081

82+
type UpgradeState = {
83+
contributorId: string;
84+
githubLogin: string;
85+
currentTier: ContributorTier;
86+
newTier: ContributorTier;
87+
};
88+
8189
type SortConfig<T extends string> = {
8290
field: T;
8391
direction: 'asc' | 'desc';
@@ -151,6 +159,16 @@ function normalizeTier(value: string): ContributorTier | null {
151159
return null;
152160
}
153161

162+
const TIER_ORDER: Record<ContributorTier, number> = {
163+
contributor: 0,
164+
ambassador: 1,
165+
champion: 2,
166+
};
167+
168+
function higherTiersFor(current: ContributorTier): ContributorTier[] {
169+
return contributorTiers.filter(t => TIER_ORDER[t] > TIER_ORDER[current]);
170+
}
171+
154172
function TierDisplay({ tier }: { tier: ContributorTier | null }) {
155173
if (!tier) {
156174
return <span className="text-muted-foreground"></span>;
@@ -325,6 +343,9 @@ export default function ContributorChampionsAdminPage() {
325343

326344
const [drillInState, setDrillInState] = useState<DrillInState | null>(null);
327345
const [enrollmentState, setEnrollmentState] = useState<EnrollmentState | null>(null);
346+
const [upgradeState, setUpgradeState] = useState<UpgradeState | null>(null);
347+
// Per-row selected upgrade tier, keyed by contributorId
348+
const [upgradeSelections, setUpgradeSelections] = useState<Record<string, ContributorTier>>({});
328349
// Enrolled table state
329350
const [enrolledPage, setEnrolledPage] = useState(1);
330351
const [enrolledFilters, setEnrolledFilters] = useState<EnrolledFilters>({
@@ -424,6 +445,26 @@ export default function ContributorChampionsAdminPage() {
424445
})
425446
);
426447

448+
const upgradeMutation = useMutation(
449+
trpc.admin.contributorChampions.upgradeTier.mutationOptions({
450+
onSuccess: result => {
451+
const creditMsg =
452+
result.creditDifferentialUsd > 0
453+
? result.creditGranted
454+
? ` — $${result.creditDifferentialUsd} top-up credit granted`
455+
: ` — credit pending (no linked account)`
456+
: '';
457+
toast.success(`Upgraded to ${result.upgradedTier}${creditMsg}`);
458+
setUpgradeState(null);
459+
setUpgradeSelections({});
460+
refreshContributorQueries();
461+
},
462+
onError: (error: { message: string }) => {
463+
toast.error(`Failed to upgrade tier: ${error.message}`);
464+
},
465+
})
466+
);
467+
427468
const syncMutation = useMutation(
428469
trpc.admin.contributorChampions.syncNow.mutationOptions({
429470
onSuccess: () => {
@@ -700,18 +741,19 @@ export default function ContributorChampionsAdminPage() {
700741
<TableHead>Credits/mo</TableHead>
701742
<TableHead>Last Grant</TableHead>
702743
<TableHead>GH Integration</TableHead>
744+
<TableHead className="text-right">Upgrade</TableHead>
703745
</TableRow>
704746
</TableHeader>
705747
<TableBody>
706748
{isLoadingTables ? (
707749
<TableRow>
708-
<TableCell colSpan={9} className="py-8 text-center">
750+
<TableCell colSpan={10} className="py-8 text-center">
709751
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
710752
</TableCell>
711753
</TableRow>
712754
) : enrolledPageRows.length === 0 ? (
713755
<TableRow>
714-
<TableCell colSpan={9} className="text-muted-foreground py-8 text-center">
756+
<TableCell colSpan={10} className="text-muted-foreground py-8 text-center">
715757
No enrolled contributors.
716758
</TableCell>
717759
</TableRow>
@@ -778,6 +820,63 @@ export default function ContributorChampionsAdminPage() {
778820
<X className="text-muted-foreground h-4 w-4" />
779821
)}
780822
</TableCell>
823+
<TableCell className="text-right">
824+
{row.enrolledTier && higherTiersFor(row.enrolledTier).length > 0 ? (
825+
<div className="flex items-center justify-end gap-1">
826+
<Select
827+
value={upgradeSelections[row.contributorId] ?? '__none__'}
828+
onValueChange={value => {
829+
const parsed = normalizeTier(value);
830+
if (!parsed) return;
831+
setUpgradeSelections(prev => ({
832+
...prev,
833+
[row.contributorId]: parsed,
834+
}));
835+
}}
836+
>
837+
<SelectTrigger className="h-8 w-[130px]">
838+
<SelectValue placeholder="Upgrade to…" />
839+
</SelectTrigger>
840+
<SelectContent>
841+
<SelectItem value="__none__" disabled>
842+
Upgrade to…
843+
</SelectItem>
844+
{higherTiersFor(row.enrolledTier).map(tier => (
845+
<SelectItem key={tier} value={tier}>
846+
{tier}
847+
</SelectItem>
848+
))}
849+
</SelectContent>
850+
</Select>
851+
<Button
852+
size="icon"
853+
className="h-8 w-8 bg-blue-600 hover:bg-blue-700"
854+
disabled={
855+
!upgradeSelections[row.contributorId] || upgradeMutation.isPending
856+
}
857+
onClick={() => {
858+
const newTier = upgradeSelections[row.contributorId];
859+
if (!newTier || !row.enrolledTier) return;
860+
setUpgradeState({
861+
contributorId: row.contributorId,
862+
githubLogin: row.githubLogin,
863+
currentTier: row.enrolledTier,
864+
newTier,
865+
});
866+
}}
867+
title={
868+
upgradeSelections[row.contributorId]
869+
? `Upgrade to ${upgradeSelections[row.contributorId]}`
870+
: 'Select a tier to upgrade to'
871+
}
872+
>
873+
<ArrowUpCircle className="h-4 w-4" />
874+
</Button>
875+
</div>
876+
) : (
877+
<span className="text-muted-foreground text-xs"></span>
878+
)}
879+
</TableCell>
781880
</TableRow>
782881
))
783882
)}
@@ -1195,6 +1294,86 @@ export default function ContributorChampionsAdminPage() {
11951294
</DialogContent>
11961295
</Dialog>
11971296

1297+
<Dialog
1298+
open={upgradeState !== null}
1299+
onOpenChange={open => {
1300+
if (!open) {
1301+
if (upgradeState) {
1302+
setUpgradeSelections(prev => {
1303+
const next = { ...prev };
1304+
delete next[upgradeState.contributorId];
1305+
return next;
1306+
});
1307+
}
1308+
setUpgradeState(null);
1309+
}
1310+
}}
1311+
>
1312+
<DialogContent className="sm:max-w-[460px]">
1313+
<DialogHeader>
1314+
<DialogTitle>Confirm tier upgrade</DialogTitle>
1315+
<DialogDescription>
1316+
Upgrade @{upgradeState?.githubLogin} from <b>{upgradeState?.currentTier}</b> to{' '}
1317+
<b>{upgradeState?.newTier}</b>.
1318+
</DialogDescription>
1319+
</DialogHeader>
1320+
1321+
{upgradeState ? (
1322+
<div className="space-y-2 text-sm">
1323+
<p>
1324+
Immediate top-up:{' '}
1325+
<b>
1326+
$
1327+
{TIER_CREDIT_USD[upgradeState.newTier] -
1328+
TIER_CREDIT_USD[upgradeState.currentTier]}{' '}
1329+
in Kilo Credits
1330+
</b>{' '}
1331+
(the difference between {upgradeState.currentTier} and {upgradeState.newTier} for
1332+
the current period).
1333+
</p>
1334+
<p>
1335+
Going forward: <b>${TIER_CREDIT_USD[upgradeState.newTier]}/month</b> at the next
1336+
renewal.
1337+
</p>
1338+
{(() => {
1339+
const matchedRow = (enrolledQuery.data ?? []).find(
1340+
r => r.contributorId === upgradeState.contributorId
1341+
);
1342+
if (!matchedRow?.linkedUserId) {
1343+
return (
1344+
<p className="text-yellow-500">
1345+
⚠️ No linked Kilo account found. The top-up credit cannot be granted until the
1346+
contributor has a Kilo account with a matching email.
1347+
</p>
1348+
);
1349+
}
1350+
return null;
1351+
})()}
1352+
</div>
1353+
) : null}
1354+
1355+
<DialogFooter>
1356+
<DialogClose asChild>
1357+
<Button variant="secondary" disabled={upgradeMutation.isPending}>
1358+
Cancel
1359+
</Button>
1360+
</DialogClose>
1361+
<Button
1362+
disabled={upgradeMutation.isPending || upgradeState === null}
1363+
onClick={() => {
1364+
if (!upgradeState) return;
1365+
void upgradeMutation.mutateAsync({
1366+
contributorId: upgradeState.contributorId,
1367+
newTier: upgradeState.newTier,
1368+
});
1369+
}}
1370+
>
1371+
{upgradeMutation.isPending ? 'Upgrading...' : 'Confirm upgrade'}
1372+
</Button>
1373+
</DialogFooter>
1374+
</DialogContent>
1375+
</Dialog>
1376+
11981377
{/* Manual Enrollment Dialog */}
11991378
<Dialog
12001379
open={manualEnrollOpen}

apps/web/src/lib/contributor-champions/service.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,121 @@ export async function enrollContributorChampion(input: {
788788
};
789789
}
790790

791+
type UpgradeResult = {
792+
upgradedTier: ContributorTier;
793+
creditDifferentialUsd: number;
794+
creditGranted: boolean;
795+
};
796+
797+
export async function upgradeContributorChampionTier(input: {
798+
contributorId: string;
799+
newTier: ContributorTier;
800+
}): Promise<UpgradeResult> {
801+
const leaderboard = await getContributorChampionLeaderboard();
802+
const row = leaderboard.find(value => value.contributorId === input.contributorId);
803+
if (!row) throw new Error('Contributor not found');
804+
if (!row.enrolledTier) throw new Error('Contributor is not enrolled');
805+
806+
// Coarse pre-check using the leaderboard snapshot. The authoritative check
807+
// happens inside the transaction after the row lock is acquired.
808+
const newCreditUsd = TIER_CREDIT_USD[input.newTier];
809+
if (newCreditUsd <= TIER_CREDIT_USD[row.enrolledTier]) {
810+
throw new Error(
811+
`New tier "${input.newTier}" must be higher than current tier "${row.enrolledTier}"`
812+
);
813+
}
814+
815+
const newCreditAmountMicrodollars = toMicrodollars(newCreditUsd);
816+
// Prefer the explicit membership link over the email-derived match, same as enrollContributorChampion.
817+
const linkedKiloUserId = row.linkedKiloUserId ?? row.linkedUserId;
818+
819+
const { creditGranted, creditDifferentialUsd } = await db.transaction(async tx => {
820+
// Lock the contributor row to serialize concurrent upgrade requests.
821+
await tx.execute(
822+
sql`SELECT id FROM contributor_champion_contributors WHERE id = ${input.contributorId} FOR UPDATE`
823+
);
824+
825+
// Re-read the membership inside the transaction after acquiring the lock so the
826+
// differential is computed from the authoritative current tier, not the
827+
// leaderboard snapshot taken before the lock. Without this, two concurrent
828+
// upgrade calls could both read the pre-upgrade tier, both compute the same
829+
// differential, and both grant — double-granting the top-up.
830+
const [membership] = await tx
831+
.select({ enrolled_tier: contributor_champion_memberships.enrolled_tier })
832+
.from(contributor_champion_memberships)
833+
.where(eq(contributor_champion_memberships.contributor_id, input.contributorId))
834+
.limit(1);
835+
836+
if (!membership) throw new Error('Contributor membership not found');
837+
if (!membership.enrolled_tier) throw new Error('Contributor is not currently enrolled');
838+
839+
const lockedCurrentTier = parseContributorTier(membership.enrolled_tier);
840+
if (!lockedCurrentTier)
841+
throw new Error(`Invalid enrolled_tier "${membership.enrolled_tier}" in DB`);
842+
const lockedCurrentCreditUsd = TIER_CREDIT_USD[lockedCurrentTier];
843+
const lockedDifferentialUsd = newCreditUsd - lockedCurrentCreditUsd;
844+
845+
if (lockedDifferentialUsd <= 0) {
846+
throw new Error(
847+
`Tier is already at or above "${input.newTier}" (current: "${membership.enrolled_tier}")`
848+
);
849+
}
850+
851+
const now = new Date().toISOString();
852+
853+
await tx
854+
.update(contributor_champion_memberships)
855+
.set({
856+
enrolled_tier: input.newTier,
857+
credit_amount_microdollars: newCreditAmountMicrodollars,
858+
updated_at: sql`now()`,
859+
})
860+
.where(eq(contributor_champion_memberships.contributor_id, input.contributorId));
861+
862+
// Grant the credit differential immediately (the top-up for the current period).
863+
let granted = false;
864+
if (lockedDifferentialUsd > 0 && linkedKiloUserId) {
865+
const [linkedUser] = await tx
866+
.select()
867+
.from(kilocode_users)
868+
.where(eq(kilocode_users.id, linkedKiloUserId))
869+
.limit(1);
870+
871+
if (linkedUser) {
872+
const result = await grantCreditForCategory(linkedUser, {
873+
credit_category: 'contributor-champion-credits',
874+
amount_usd: lockedDifferentialUsd,
875+
expiry_hours: CREDIT_EXPIRY_HOURS,
876+
counts_as_selfservice: false,
877+
dbOrTx: tx,
878+
});
879+
if (!result.success) {
880+
throw new Error('Failed to grant top-up credit; rolling back tier upgrade');
881+
}
882+
granted = true;
883+
}
884+
}
885+
886+
// Reset the renewal clock after a successful top-up grant. Without this,
887+
// refreshContributorChampionCredits could see a stale credits_last_granted_at
888+
// and immediately grant the full new monthly amount on top of the top-up.
889+
if (granted) {
890+
await tx
891+
.update(contributor_champion_memberships)
892+
.set({ credits_last_granted_at: now })
893+
.where(eq(contributor_champion_memberships.contributor_id, input.contributorId));
894+
}
895+
896+
return { creditGranted: granted, creditDifferentialUsd: lockedDifferentialUsd };
897+
});
898+
899+
return {
900+
upgradedTier: input.newTier,
901+
creditDifferentialUsd,
902+
creditGranted,
903+
};
904+
}
905+
791906
export async function getEnrolledContributorChampions(): Promise<LeaderboardRow[]> {
792907
const leaderboard = await getContributorChampionLeaderboard();
793908
return leaderboard.filter(row => row.enrolledTier !== null || row.enrolledAt !== null);

0 commit comments

Comments
 (0)