diff --git a/src/lib/promoCreditCategories.ts b/src/lib/promoCreditCategories.ts index 86b7a5bab..2936fcdf8 100644 --- a/src/lib/promoCreditCategories.ts +++ b/src/lib/promoCreditCategories.ts @@ -177,6 +177,13 @@ const nonSelfServicePromos: readonly NonSelfServicePromoCreditCategoryConfig[] = is_idempotent: true, expiry_hours: 30 * 24, }, + { + credit_category: 'cli-v1-rollout', + description: 'CLI V1 Rollout - $1 credit with 7 day expiry', + amount_usd: 1, + is_idempotent: true, + expiry_hours: 7 * 24, + }, { credit_category: 'payment-tripled', diff --git a/src/scripts/d2025-02-04_cli-v1-rollout-README.md b/src/scripts/d2025-02-04_cli-v1-rollout-README.md new file mode 100644 index 000000000..30fa1a07a --- /dev/null +++ b/src/scripts/d2025-02-04_cli-v1-rollout-README.md @@ -0,0 +1,55 @@ +# CLI V1 Rollout - Inactive User Re-engagement + +Grant $1 credit (7-day expiry) to users who used Kilo before but not in the last 30 days. + +## Two-Phase Approach + +1. **Phase 1 (cohort):** Tags eligible users into `cli-v1-rollout` cohort via SQL +2. **Phase 2 (grant):** Grants credits to cohort members + +Both scripts are idempotent - safe to re-run. + +## Running the Scripts + +**Note:** Production env vars are required. Test `vercel env run` first: + +```bash +vercel env run --environment=production -- node -e "console.log(process.env.POSTGRES_URL ? 'OK' : 'NOT SET')" +``` + +### Phase 1: Tag Cohort + +```bash +# Dry run (default) - shows count of users that would be tagged +vercel env run --environment=production -- pnpm script src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts + +# Apply +vercel env run --environment=production -- pnpm script src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts --apply +``` + +### Phase 2: Grant Credits + +```bash +# Dry run (default) - shows count of cohort members +vercel env run --environment=production -- pnpm script src/scripts/d2025-02-04_cli-v1-rollout-grant.ts + +# Apply +vercel env run --environment=production -- pnpm script src/scripts/d2025-02-04_cli-v1-rollout-grant.ts --apply +``` + +## Rollout Checklist + +1. [ ] Test `vercel env run` with simple command +2. [ ] Run Phase 1 dry run, review user count +3. [ ] Run Phase 1 with `--apply` +4. [ ] Run Phase 2 dry run, verify cohort count matches +5. [ ] Run Phase 2 with `--apply` +6. [ ] Verify credits granted in admin panel + +## Fallback (if vercel env run doesn't work) + +```bash +vercel env pull .env.production +source .env.production # or use dotenv-cli +pnpm script src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts +``` diff --git a/src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts b/src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts new file mode 100644 index 000000000..3ead0dfae --- /dev/null +++ b/src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts @@ -0,0 +1,97 @@ +import { db } from '@/lib/drizzle'; +import { kilocode_users, microdollar_usage } from '@/db/schema'; +import { sql } from 'drizzle-orm'; + +/** + * Phase 1: Tag eligible inactive users into the 'cli-v1-rollout' cohort. + * + * Eligible = non-blocked users who have used Kilo before but not in the last 30 days. + * + * Uses subqueries so the database handles the filtering — no large arrays + * materialized in application memory. + * + * Idempotent: users already in the cohort are skipped via the + * NOT (cohorts ? ) guard. + * + * Usage: + * pnpm script src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts # dry run + * pnpm script src/scripts/d2025-02-04_cli-v1-rollout-cohort.ts --apply # apply + */ + +const INACTIVE_DAYS = 30; +const COHORT_NAME = 'cli-v1-rollout'; + +export async function tagInactiveUsersIntoCohort(options: { + cohortName: string; + inactiveDays: number; + dryRun: boolean; +}): Promise<{ tagged: number }> { + const cutoffIso = new Date(Date.now() - options.inactiveDays * 24 * 60 * 60 * 1000).toISOString(); + const now = Date.now(); + + const everUsed = sql`(SELECT DISTINCT ${microdollar_usage.kilo_user_id} FROM ${microdollar_usage})`; + const recentlyActive = sql`(SELECT DISTINCT ${microdollar_usage.kilo_user_id} FROM ${microdollar_usage} WHERE ${microdollar_usage.created_at} >= ${cutoffIso})`; + + if (options.dryRun) { + const countResult = await db.execute<{ count: string }>(sql` + SELECT COUNT(*) AS count + FROM ${kilocode_users} + WHERE ${kilocode_users.blocked_reason} IS NULL + AND ${kilocode_users.id} IN ${everUsed} + AND ${kilocode_users.id} NOT IN ${recentlyActive} + AND NOT (${kilocode_users.cohorts} ? ${options.cohortName}) + `); + + return { tagged: Number(countResult.rows[0].count) }; + } + + const result = await db.execute<{ count: string }>(sql` + WITH updated AS ( + UPDATE ${kilocode_users} + SET cohorts = cohorts || jsonb_build_object(${options.cohortName}::text, ${now}::bigint) + WHERE ${kilocode_users.blocked_reason} IS NULL + AND ${kilocode_users.id} IN ${everUsed} + AND ${kilocode_users.id} NOT IN ${recentlyActive} + AND NOT (cohorts ? ${options.cohortName}::text) + RETURNING ${kilocode_users.id} + ) + SELECT COUNT(*) AS count FROM updated + `); + + return { tagged: Number(result.rows[0].count) }; +} + +async function run() { + const isDryRun = !process.argv.includes('--apply'); + + console.log('Phase 1: Tagging inactive users into cohort...\n'); + if (isDryRun) { + console.log('DRY RUN MODE - No changes will be made'); + console.log('Run with --apply flag to write cohort tags\n'); + } + + const { tagged } = await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: INACTIVE_DAYS, + dryRun: isDryRun, + }); + + if (isDryRun) { + console.log(`Would tag ${tagged} users into cohort '${COHORT_NAME}'`); + } else { + console.log(`Tagged ${tagged} users into cohort '${COHORT_NAME}'`); + } +} + +// Only run if executed directly (not imported as a module for testing) +if (require.main === module || process.argv[1]?.endsWith('d2025-02-04_cli-v1-rollout-cohort.ts')) { + run() + .then(() => { + console.log('\nScript completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\nScript failed:', error); + process.exit(1); + }); +} diff --git a/src/scripts/d2025-02-04_cli-v1-rollout-grant.ts b/src/scripts/d2025-02-04_cli-v1-rollout-grant.ts new file mode 100644 index 000000000..cd2ff3a29 --- /dev/null +++ b/src/scripts/d2025-02-04_cli-v1-rollout-grant.ts @@ -0,0 +1,135 @@ +import { db } from '@/lib/drizzle'; +import { kilocode_users, type User } from '@/db/schema'; +import { sql, gt, and, isNull } from 'drizzle-orm'; +import { grantCreditForCategory } from '@/lib/promotionalCredits'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +/** + * Phase 2: Grant $1 credits to all users in the 'cli-v1-rollout' cohort. + * + * Reads users tagged by Phase 1 (cohort script) and grants them credits. + * Idempotent — safe to re-run; users who already received the credit are skipped. + * + * Usage: + * pnpm script src/scripts/d2025-02-04_cli-v1-rollout-grant.ts # dry run + * pnpm script src/scripts/d2025-02-04_cli-v1-rollout-grant.ts --apply # apply + */ + +const COHORT_NAME = 'cli-v1-rollout'; + +export type GrantCohortCreditsResult = { + granted: number; + skipped: number; + failed: number; + failedUserIds: string[]; +}; + +export async function grantCreditsToCohort(options: { + cohortName: string; + creditCategory: string; +}): Promise { + let granted = 0; + let skipped = 0; + let failed = 0; + const failedUserIds: string[] = []; + + let lastUserId: string | null = null; + let hasMore = true; + + while (hasMore) { + const cohortFilter = sql`${kilocode_users.cohorts} ? ${options.cohortName}`; + const notBlocked = isNull(kilocode_users.blocked_reason); + const baseFilter = and(cohortFilter, notBlocked); + const users: User[] = await db.query.kilocode_users.findMany({ + where: lastUserId ? and(baseFilter, gt(kilocode_users.id, lastUserId)) : baseFilter, + orderBy: (kilocode_users, { asc }) => [asc(kilocode_users.id)], + limit: 1000, + }); + + if (users.length === 0) { + hasMore = false; + break; + } + + lastUserId = users[users.length - 1].id; + hasMore = users.length === 1000; + + for (const user of users) { + try { + const result = await grantCreditForCategory(user, { + credit_category: options.creditCategory, + counts_as_selfservice: false, + }); + + if (!result.success) { + if (result.message.includes('already been applied')) { + skipped++; + } else { + failed++; + failedUserIds.push(user.id); + } + } else { + granted++; + } + } catch { + failed++; + failedUserIds.push(user.id); + } + } + } + + return { granted, skipped, failed, failedUserIds }; +} + +async function run() { + const isDryRun = !process.argv.includes('--apply'); + + console.log('Phase 2: Granting credits to cohort members...'); + console.log(`Credit: $1 with 7-day expiry for users in cohort '${COHORT_NAME}'\n`); + + if (isDryRun) { + console.log('DRY RUN MODE - No changes will be made'); + console.log('Run with --apply flag to grant credits\n'); + + // In dry run, just count cohort members + const cohortFilter = sql`${kilocode_users.cohorts} ? ${COHORT_NAME}`; + const users = await db.query.kilocode_users.findMany({ where: cohortFilter }); + console.log(`Would grant credits to ${users.length} cohort members`); + return; + } + + const scriptStartTime = Date.now(); + const result = await grantCreditsToCohort({ + cohortName: COHORT_NAME, + creditCategory: COHORT_NAME, + }); + + const totalSeconds = (Date.now() - scriptStartTime) / 1000; + console.log('\n' + '='.repeat(60)); + console.log('Done!'); + console.log(` Granted: ${result.granted}`); + console.log(` Already applied: ${result.skipped}`); + console.log(` Failed: ${result.failed}`); + console.log(` Time: ${totalSeconds.toFixed(1)}s`); + + if (result.failedUserIds.length > 0) { + const logFileName = `failed-users-cli-v1-rollout-${new Date().toISOString().replace(/[:.]/g, '-')}.log`; + const logFilePath = path.join(process.cwd(), logFileName); + await fs.writeFile(logFilePath, result.failedUserIds.join('\n') + '\n', 'utf-8'); + console.log(`\n${result.failedUserIds.length} failed user IDs written to: ${logFileName}`); + } +} + +// Only run if executed directly (not imported as a module for testing) +if (require.main === module || process.argv[1]?.endsWith('d2025-02-04_cli-v1-rollout-grant.ts')) { + run() + .then(() => { + console.log('\nScript completed successfully'); + process.exit(0); + }) + .catch(error => { + console.error('\nScript failed:', error); + process.exit(1); + }); +} diff --git a/src/tests/cli-v1-rollout.test.ts b/src/tests/cli-v1-rollout.test.ts new file mode 100644 index 000000000..5afb702bf --- /dev/null +++ b/src/tests/cli-v1-rollout.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect } from '@jest/globals'; +import { db } from '@/lib/drizzle'; +import { kilocode_users, credit_transactions } from '@/db/schema'; +import { eq } from 'drizzle-orm'; +import { insertTestUser } from './helpers/user.helper'; +import { insertUsageWithOverrides } from './helpers/microdollar-usage.helper'; +import { tagInactiveUsersIntoCohort } from '@/scripts/d2025-02-04_cli-v1-rollout-cohort'; +import { grantCreditsToCohort } from '@/scripts/d2025-02-04_cli-v1-rollout-grant'; +import { subDays } from 'date-fns'; + +const COHORT_NAME = 'test-cli-v1-rollout'; + +async function getUserCohorts(userId: string): Promise> { + const rows = await db + .select({ cohorts: kilocode_users.cohorts }) + .from(kilocode_users) + .where(eq(kilocode_users.id, userId)); + return rows[0].cohorts; +} + +async function getUserCredits(userId: string) { + return db.select().from(credit_transactions).where(eq(credit_transactions.kilo_user_id, userId)); +} + +describe('CLI V1 Rollout', () => { + describe('Phase 1: tagInactiveUsersIntoCohort', () => { + it('should tag users who used Kilo >30 days ago but not recently', async () => { + const user = await insertTestUser(); + // Usage from 60 days ago + await insertUsageWithOverrides({ + kilo_user_id: user.id, + created_at: subDays(new Date(), 60).toISOString(), + }); + + const { tagged } = await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: 30, + dryRun: false, + }); + + expect(tagged).toBeGreaterThanOrEqual(1); + const cohorts = await getUserCohorts(user.id); + expect(cohorts).toHaveProperty(COHORT_NAME); + }); + + it('should NOT tag users who used Kilo recently', async () => { + const user = await insertTestUser(); + // Usage from 5 days ago (recent) + await insertUsageWithOverrides({ + kilo_user_id: user.id, + created_at: subDays(new Date(), 5).toISOString(), + }); + + await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: 30, + dryRun: false, + }); + + const cohorts = await getUserCohorts(user.id); + expect(cohorts).not.toHaveProperty(COHORT_NAME); + }); + + it('should NOT tag users who never used Kilo', async () => { + const user = await insertTestUser(); + // No usage records at all + + await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: 30, + dryRun: false, + }); + + const cohorts = await getUserCohorts(user.id); + expect(cohorts).not.toHaveProperty(COHORT_NAME); + }); + + it('should NOT tag blocked users', async () => { + const user = await insertTestUser({ blocked_reason: 'abuse' }); + await insertUsageWithOverrides({ + kilo_user_id: user.id, + created_at: subDays(new Date(), 60).toISOString(), + }); + + await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: 30, + dryRun: false, + }); + + const cohorts = await getUserCohorts(user.id); + expect(cohorts).not.toHaveProperty(COHORT_NAME); + }); + + it('should be idempotent — not re-tag already tagged users', async () => { + const user = await insertTestUser(); + await insertUsageWithOverrides({ + kilo_user_id: user.id, + created_at: subDays(new Date(), 60).toISOString(), + }); + + // First run + const first = await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: 30, + dryRun: false, + }); + expect(first.tagged).toBeGreaterThanOrEqual(1); + + const cohortsAfterFirst = await getUserCohorts(user.id); + const firstTimestamp = cohortsAfterFirst[COHORT_NAME]; + + // Second run — should not re-tag + const second = await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: 30, + dryRun: false, + }); + expect(second.tagged).toBe(0); + + // Timestamp should be unchanged + const cohortsAfterSecond = await getUserCohorts(user.id); + expect(cohortsAfterSecond[COHORT_NAME]).toBe(firstTimestamp); + }); + + it('should return count without writing in dry-run mode', async () => { + const user = await insertTestUser(); + await insertUsageWithOverrides({ + kilo_user_id: user.id, + created_at: subDays(new Date(), 60).toISOString(), + }); + + const { tagged } = await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: 30, + dryRun: true, + }); + + expect(tagged).toBeGreaterThanOrEqual(1); + + // Should NOT have been tagged + const cohorts = await getUserCohorts(user.id); + expect(cohorts).not.toHaveProperty(COHORT_NAME); + }); + }); + + describe('Phase 2: grantCreditsToCohort', () => { + it('should grant credits to cohort members', async () => { + const user = await insertTestUser(); + // Manually tag into cohort + await db + .update(kilocode_users) + .set({ cohorts: { [COHORT_NAME]: Date.now() } }) + .where(eq(kilocode_users.id, user.id)); + + const result = await grantCreditsToCohort({ + cohortName: COHORT_NAME, + creditCategory: 'cli-v1-rollout', + }); + + expect(result.granted).toBeGreaterThanOrEqual(1); + expect(result.failed).toBe(0); + }); + + it('should skip users who already received the credit (idempotent)', async () => { + const user = await insertTestUser(); + await db + .update(kilocode_users) + .set({ cohorts: { [COHORT_NAME]: Date.now() } }) + .where(eq(kilocode_users.id, user.id)); + + // First grant + const first = await grantCreditsToCohort({ + cohortName: COHORT_NAME, + creditCategory: 'cli-v1-rollout', + }); + expect(first.granted).toBeGreaterThanOrEqual(1); + + // Second grant — should skip + const second = await grantCreditsToCohort({ + cohortName: COHORT_NAME, + creditCategory: 'cli-v1-rollout', + }); + expect(second.skipped).toBeGreaterThanOrEqual(1); + expect(second.granted).toBe(0); + }); + + it('should not grant to users not in the cohort', async () => { + const user = await insertTestUser(); + // No cohort tag + + await grantCreditsToCohort({ + cohortName: COHORT_NAME, + creditCategory: 'cli-v1-rollout', + }); + + // Should not have processed this user at all + const credits = await getUserCredits(user.id); + const relevant = credits.filter(c => c.credit_category === 'cli-v1-rollout'); + expect(relevant).toHaveLength(0); + }); + }); + + describe('End-to-end: Phase 1 + Phase 2', () => { + it('should tag inactive users and then grant them credits', async () => { + const inactiveUser = await insertTestUser(); + const activeUser = await insertTestUser(); + const neverUsedUser = await insertTestUser(); + + // Inactive user: usage 60 days ago + await insertUsageWithOverrides({ + kilo_user_id: inactiveUser.id, + created_at: subDays(new Date(), 60).toISOString(), + }); + + // Active user: usage 5 days ago + await insertUsageWithOverrides({ + kilo_user_id: activeUser.id, + created_at: subDays(new Date(), 5).toISOString(), + }); + + // Never-used user: no usage records + + // Phase 1: Tag + await tagInactiveUsersIntoCohort({ + cohortName: COHORT_NAME, + inactiveDays: 30, + dryRun: false, + }); + + // Verify only inactive user was tagged + expect(await getUserCohorts(inactiveUser.id)).toHaveProperty(COHORT_NAME); + expect(await getUserCohorts(activeUser.id)).not.toHaveProperty(COHORT_NAME); + expect(await getUserCohorts(neverUsedUser.id)).not.toHaveProperty(COHORT_NAME); + + // Phase 2: Grant + const grantResult = await grantCreditsToCohort({ + cohortName: COHORT_NAME, + creditCategory: 'cli-v1-rollout', + }); + + expect(grantResult.granted).toBeGreaterThanOrEqual(1); + expect(grantResult.failed).toBe(0); + + // Verify only inactive user got credits + const inactiveCredits = await getUserCredits(inactiveUser.id); + expect(inactiveCredits.filter(c => c.credit_category === 'cli-v1-rollout')).toHaveLength(1); + + const activeCredits = await getUserCredits(activeUser.id); + expect(activeCredits.filter(c => c.credit_category === 'cli-v1-rollout')).toHaveLength(0); + + const neverUsedCredits = await getUserCredits(neverUsedUser.id); + expect(neverUsedCredits.filter(c => c.credit_category === 'cli-v1-rollout')).toHaveLength(0); + }); + }); +});