Skip to content
Open
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
109 changes: 109 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,115 @@ 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?.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({
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,
});
granted = result.success;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Successful upgrade top-ups do not advance the grant timestamp

When a contributor tier with no prior credits, such as contributor, is manually upgraded and this call grants the immediate top-up, credits_last_granted_at stays NULL. For memberships with linked_kilo_user_id set, the next credit refresh sees the row as due immediately and can grant the new monthly amount again. Mirror enrollment by setting credits_last_granted_at when the top-up succeeds.

}
}

// 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
21 changes: 21 additions & 0 deletions apps/web/src/routers/admin/contributor-champions-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
manualEnrollContributor,
searchKiloUsersByEmail,
syncContributorChampionData,
upgradeContributorChampionTier,
upsertContributorSelectedTier,
} from '@/lib/contributor-champions/service';
import * as z from 'zod';
Expand Down Expand Up @@ -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();
}),
Expand Down