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
183 changes: 181 additions & 2 deletions apps/web/src/app/admin/contributors/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { SortableButton } from '../components/SortableButton';
import {
AlertCircle,
ArrowUpCircle,
ChevronLeft,
Check,
ChevronRight,
Expand Down Expand Up @@ -78,6 +79,13 @@ type EnrollmentState = {
tier: ContributorTier;
};

type UpgradeState = {
contributorId: string;
githubLogin: string;
currentTier: ContributorTier;
newTier: ContributorTier;
};

type SortConfig<T extends string> = {
field: T;
direction: 'asc' | 'desc';
Expand Down Expand Up @@ -151,6 +159,16 @@ function normalizeTier(value: string): ContributorTier | null {
return null;
}

const TIER_ORDER: Record<ContributorTier, number> = {
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 <span className="text-muted-foreground">—</span>;
Expand Down Expand Up @@ -325,6 +343,9 @@ export default function ContributorChampionsAdminPage() {

const [drillInState, setDrillInState] = useState<DrillInState | null>(null);
const [enrollmentState, setEnrollmentState] = useState<EnrollmentState | null>(null);
const [upgradeState, setUpgradeState] = useState<UpgradeState | null>(null);
// Per-row selected upgrade tier, keyed by contributorId
const [upgradeSelections, setUpgradeSelections] = useState<Record<string, ContributorTier>>({});
// Enrolled table state
const [enrolledPage, setEnrolledPage] = useState(1);
const [enrolledFilters, setEnrolledFilters] = useState<EnrolledFilters>({
Expand Down Expand Up @@ -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: () => {
Expand Down Expand Up @@ -700,18 +741,19 @@ export default function ContributorChampionsAdminPage() {
<TableHead>Credits/mo</TableHead>
<TableHead>Last Grant</TableHead>
<TableHead>GH Integration</TableHead>
<TableHead className="text-right">Upgrade</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoadingTables ? (
<TableRow>
<TableCell colSpan={9} className="py-8 text-center">
<TableCell colSpan={10} className="py-8 text-center">
<Loader2 className="mx-auto h-4 w-4 animate-spin" />
</TableCell>
</TableRow>
) : enrolledPageRows.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-muted-foreground py-8 text-center">
<TableCell colSpan={10} className="text-muted-foreground py-8 text-center">
No enrolled contributors.
</TableCell>
</TableRow>
Expand Down Expand Up @@ -778,6 +820,63 @@ export default function ContributorChampionsAdminPage() {
<X className="text-muted-foreground h-4 w-4" />
)}
</TableCell>
<TableCell className="text-right">
{row.enrolledTier && higherTiersFor(row.enrolledTier).length > 0 ? (
<div className="flex items-center justify-end gap-1">
<Select
value={upgradeSelections[row.contributorId] ?? '__none__'}
onValueChange={value => {
const parsed = normalizeTier(value);
if (!parsed) return;
setUpgradeSelections(prev => ({
...prev,
[row.contributorId]: parsed,
}));
}}
>
<SelectTrigger className="h-8 w-[130px]">
<SelectValue placeholder="Upgrade to…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" disabled>
Upgrade to…
</SelectItem>
{higherTiersFor(row.enrolledTier).map(tier => (
<SelectItem key={tier} value={tier}>
{tier}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="icon"
className="h-8 w-8 bg-blue-600 hover:bg-blue-700"
disabled={
!upgradeSelections[row.contributorId] || upgradeMutation.isPending
}
onClick={() => {
const newTier = upgradeSelections[row.contributorId];
if (!newTier || !row.enrolledTier) return;
setUpgradeState({
contributorId: row.contributorId,
githubLogin: row.githubLogin,
currentTier: row.enrolledTier,
newTier,
});
}}
title={
upgradeSelections[row.contributorId]
? `Upgrade to ${upgradeSelections[row.contributorId]}`
: 'Select a tier to upgrade to'
}
>
<ArrowUpCircle className="h-4 w-4" />
</Button>
</div>
) : (
<span className="text-muted-foreground text-xs">—</span>
)}
</TableCell>
</TableRow>
))
)}
Expand Down Expand Up @@ -1195,6 +1294,86 @@ export default function ContributorChampionsAdminPage() {
</DialogContent>
</Dialog>

<Dialog
open={upgradeState !== null}
onOpenChange={open => {
if (!open) {
if (upgradeState) {
setUpgradeSelections(prev => {
const next = { ...prev };
delete next[upgradeState.contributorId];
return next;
});
}
setUpgradeState(null);
}
}}
>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>Confirm tier upgrade</DialogTitle>
<DialogDescription>
Upgrade @{upgradeState?.githubLogin} from <b>{upgradeState?.currentTier}</b> to{' '}
<b>{upgradeState?.newTier}</b>.
</DialogDescription>
</DialogHeader>

{upgradeState ? (
<div className="space-y-2 text-sm">
<p>
Immediate top-up:{' '}
<b>
$
{TIER_CREDIT_USD[upgradeState.newTier] -
TIER_CREDIT_USD[upgradeState.currentTier]}{' '}
in Kilo Credits
</b>{' '}
(the difference between {upgradeState.currentTier} and {upgradeState.newTier} for
the current period).
</p>
<p>
Going forward: <b>${TIER_CREDIT_USD[upgradeState.newTier]}/month</b> at the next
renewal.
</p>
{(() => {
const matchedRow = (enrolledQuery.data ?? []).find(
r => r.contributorId === upgradeState.contributorId
);
if (!matchedRow?.linkedUserId) {
return (
<p className="text-yellow-500">
⚠️ No linked Kilo account found. The top-up credit cannot be granted until the
contributor has a Kilo account with a matching email.
</p>
);
}
return null;
})()}
</div>
) : null}

<DialogFooter>
<DialogClose asChild>
<Button variant="secondary" disabled={upgradeMutation.isPending}>
Cancel
</Button>
</DialogClose>
<Button
disabled={upgradeMutation.isPending || upgradeState === null}
onClick={() => {
if (!upgradeState) return;
void upgradeMutation.mutateAsync({
contributorId: upgradeState.contributorId,
newTier: upgradeState.newTier,
});
}}
>
{upgradeMutation.isPending ? 'Upgrading...' : 'Confirm upgrade'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

{/* Manual Enrollment Dialog */}
<Dialog
open={manualEnrollOpen}
Expand Down
115 changes: 115 additions & 0 deletions apps/web/src/lib/contributor-champions/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,121 @@ export async function enrollContributorChampion(input: {
};
}

type UpgradeResult = {
upgradedTier: ContributorTier;
creditDifferentialUsd: number;
creditGranted: boolean;
};

export async function upgradeContributorChampionTier(input: {
contributorId: string;
newTier: ContributorTier;
}): Promise<UpgradeResult> {
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');

// 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 <= TIER_CREDIT_USD[row.enrolledTier]) {
throw new Error(
`New tier "${input.newTier}" must be higher than current tier "${row.enrolledTier}"`
);
}

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, 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`
);

// 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) throw new Error('Contributor membership not found');
if (!membership.enrolled_tier) throw new Error('Contributor is not currently enrolled');

const lockedCurrentTier = parseContributorTier(membership.enrolled_tier);
if (!lockedCurrentTier)
throw new Error(`Invalid enrolled_tier "${membership.enrolled_tier}" in DB`);
const lockedCurrentCreditUsd = TIER_CREDIT_USD[lockedCurrentTier];
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({
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 (lockedDifferentialUsd > 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: lockedDifferentialUsd,
expiry_hours: CREDIT_EXPIRY_HOURS,
counts_as_selfservice: false,
dbOrTx: tx,
});
if (!result.success) {
throw new Error('Failed to grant top-up credit; rolling back tier upgrade');
}
granted = true;
}
}

// 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 {
upgradedTier: input.newTier,
creditDifferentialUsd,
creditGranted,
};
}

export async function getEnrolledContributorChampions(): Promise<LeaderboardRow[]> {
const leaderboard = await getContributorChampionLeaderboard();
return leaderboard.filter(row => row.enrolledTier !== null || row.enrolledAt !== null);
Expand Down
Loading