From b98ab02edccf2e3ccefe75c060d46715a2f4bf58 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 15 Jun 2026 11:07:15 +0000 Subject: [PATCH] Fix critical build and credit regressions Co-authored-by: Cole Collins --- app/api/analyses/[id]/analyze/route.ts | 40 +++--- app/api/build-app/route.ts | 105 ++++++++++----- app/api/generate-scaffold/route.ts | 60 +++++---- lib/credits.ts | 180 +++++++++++-------------- 4 files changed, 198 insertions(+), 187 deletions(-) diff --git a/app/api/analyses/[id]/analyze/route.ts b/app/api/analyses/[id]/analyze/route.ts index 65e9bec..138e3b5 100644 --- a/app/api/analyses/[id]/analyze/route.ts +++ b/app/api/analyses/[id]/analyze/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { generateText } from 'ai' -import { getCreditBalance, deductCredits, CREDITS } from '@/lib/credits' +import { deductCredits, CREDITS } from '@/lib/credits' +import { getCurrentUser } from '@/lib/auth' const model = 'openai/gpt-4-turbo' @@ -21,28 +22,14 @@ interface AppSuggestion { export async function POST(request: NextRequest) { try { - const { analysisId, selectedRepos, userId } = (await request.json()) as { - analysisId: string - selectedRepos: SelectedRepository[] - userId: string + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) } - // Check credit balance before proceeding - if (!userId) { - return NextResponse.json({ error: 'User ID required' }, { status: 401 }) - } - - const currentBalance = await getCreditBalance(userId) - if (currentBalance < CREDITS.ANALYSIS_COST) { - return NextResponse.json( - { - error: 'Insufficient credits', - required: CREDITS.ANALYSIS_COST, - available: currentBalance, - message: 'Upgrade to Pro to get unlimited analyses with 3,000 monthly credits.', - }, - { status: 402 } - ) + const { analysisId, selectedRepos } = (await request.json()) as { + analysisId: string + selectedRepos: SelectedRepository[] } // Get all repo files from database @@ -95,16 +82,19 @@ Return as JSON array of app suggestions. Focus on practical, buildable applicati } // Deduct credits for successful analysis - const deductResult = await deductCredits(userId, CREDITS.ANALYSIS_COST, 'analysis', { + const deductResult = await deductCredits(user.id, CREDITS.ANALYSIS_COST, 'analysis', { analysisId, selectedRepos: selectedRepos.map((r) => r.name), }) if (!deductResult.success) { - console.error('Failed to deduct credits:', deductResult.error) return NextResponse.json( - { error: 'Failed to process analysis' }, - { status: 500 } + { + error: deductResult.error ?? 'Insufficient credits', + required: CREDITS.ANALYSIS_COST, + message: 'Upgrade to Pro to get unlimited analyses with 3,000 monthly credits.', + }, + { status: 402 } ) } diff --git a/app/api/build-app/route.ts b/app/api/build-app/route.ts index 45c0852..cd39005 100644 --- a/app/api/build-app/route.ts +++ b/app/api/build-app/route.ts @@ -1,6 +1,7 @@ import { NextRequest } from 'next/server' -import { generateWithGateway } from '@/lib/ai-gateway' +import { aiConfigErrorMessage, generateWithGateway, isAiConfigured } from '@/lib/ai-gateway' import { getCurrentUser } from '@/lib/auth' +import { CREDITS, deductCredits, refundCredits } from '@/lib/credits' import { getSubscriptionByGithubId, upsertSubscription, type AppBlueprint } from '@/lib/queries' import { hasProAccess } from '@/lib/pro-access' @@ -56,20 +57,29 @@ Just the file content itself, ready to save.` /** Build the list of all files to generate */ function getFilesToGenerate(blueprint: BuildAppRequest['blueprint']): Array<{ path: string; purpose: string }> { - const files: Array<{ path: string; purpose: string }> = [] + const filesByPath = new Map() + const addFile = (path: string, purpose: string) => { + if (!filesByPath.has(path)) { + filesByPath.set(path, { path, purpose }) + } + } // Missing files from the blueprint for (const f of blueprint.missing_files) { - files.push({ path: f.name, purpose: f.purpose }) + addFile(f.name, f.purpose) } // Standard project files - files.push({ path: 'README.md', purpose: 'Comprehensive setup, usage, and API documentation' }) - files.push({ path: 'package.json', purpose: 'Project dependencies and scripts for the tech stack' }) - files.push({ path: '.env.example', purpose: 'All required environment variables with placeholder values' }) - files.push({ path: '.gitignore', purpose: 'Gitignore file appropriate for this stack' }) + addFile('README.md', 'Comprehensive setup, usage, and API documentation') + addFile('package.json', 'Project dependencies and scripts for the tech stack') + addFile('.env.example', 'All required environment variables with placeholder values') + addFile('.gitignore', 'Gitignore file appropriate for this stack') + + return Array.from(filesByPath.values()) +} - return files +function gitHubContentsPath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/') } async function createGitHubRepo( @@ -88,7 +98,7 @@ async function createGitHubRepo( body: JSON.stringify({ name: repoName, description, - private: false, + private: true, auto_init: false, }), }) @@ -111,7 +121,7 @@ async function pushFileToGitHub( ): Promise { const encoded = Buffer.from(content).toString('base64') const res = await fetch( - `https://api.github.com/repos/${username}/${repoName}/contents/${path}`, + `https://api.github.com/repos/${username}/${repoName}/contents/${gitHubContentsPath(path)}`, { method: 'PUT', headers: { @@ -128,7 +138,7 @@ async function pushFileToGitHub( if (!res.ok) { const err = (await res.json()) as { message?: string } - console.warn(`[build-app] Failed to push ${path}: ${err.message}`) + throw new Error(err.message ?? `Failed to push ${path}`) } } @@ -190,7 +200,7 @@ async function pushFileToGitLab( if (!res.ok) { const err = (await res.json()) as { message?: string } - console.warn(`[build-app] Failed to push ${path} to GitLab: ${err.message}`) + throw new Error(err.message ?? `Failed to push ${path} to GitLab`) } } @@ -198,15 +208,29 @@ export async function POST(request: NextRequest) { const encoder = new TextEncoder() const stream = new ReadableStream({ async start(controller) { + let closed = false const send = (data: object) => { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) } + const close = () => { + if (!closed) { + closed = true + controller.close() + } + } + let charged = false + let chargedUserId = '' + let refundMetadata: Record = {} try { + if (!isAiConfigured()) { + send({ step: 'error', message: aiConfigErrorMessage() }) + return + } + const user = await getCurrentUser() if (!user) { send({ step: 'error', message: 'Sign in before building an app.' }) - controller.close() return } @@ -216,20 +240,30 @@ export async function POST(request: NextRequest) { } if (!hasProAccess(user, sub)) { send({ step: 'error', message: 'Build This App is available on paid plans. Upgrade to create and push a generated repo.' }) - controller.close() return } const body = (await request.json()) as BuildAppRequest const { platform, repoName, blueprint } = body - if (!repoName?.trim()) { - send({ step: 'error', message: 'Repository name is required.' }) - controller.close() + if (!repoName?.trim() || !blueprint?.name || !Array.isArray(blueprint.missing_files) || !Array.isArray(blueprint.existing_files) || !Array.isArray(blueprint.technologies)) { + send({ step: 'error', message: 'Repository name and blueprint details are required.' }) return } const cleanRepoName = repoName.trim().replace(/\s+/g, '-').toLowerCase() + const creditResult = await deductCredits(user.id, CREDITS.BUILD_APP_COST, 'build_app', { + repoName: cleanRepoName, + platform, + blueprintName: blueprint.name, + }) + if (!creditResult.success) { + send({ step: 'error', message: creditResult.error ?? 'Insufficient credits.' }) + return + } + charged = true + chargedUserId = user.id + refundMetadata = { repoName: cleanRepoName, platform, blueprintName: blueprint.name } // Step 1 — determine files to generate const filesToGenerate = getFilesToGenerate(blueprint) @@ -264,12 +298,7 @@ export async function POST(request: NextRequest) { gitlabBranch = project.default_branch || 'main' } } catch (e) { - send({ - step: 'error', - message: `Could not create repository: ${e instanceof Error ? e.message : String(e)}. Make sure you are connected to ${platform === 'github' ? 'GitHub' : 'GitLab'}.`, - }) - controller.close() - return + throw new Error(`Could not create repository: ${e instanceof Error ? e.message : String(e)}. Make sure you are connected to ${platform === 'github' ? 'GitHub' : 'GitLab'}.`) } send({ step: 'repo_created', message: 'Repository created. Generating and pushing files…', repoUrl }) @@ -280,12 +309,11 @@ export async function POST(request: NextRequest) { for (const { path, purpose } of filesToGenerate) { // Generate this file - let content: string - try { - content = await generateSingleFile(blueprint, path, purpose, user.id) - } catch (e) { - console.warn(`[build-app] Failed to generate ${path}:`, e) - content = `# Error generating ${path}\n# ${e instanceof Error ? e.message : String(e)}\n` + const content = await generateSingleFile(blueprint, path, purpose, user.id).catch((e) => { + throw new Error(`Failed to generate ${path}: ${e instanceof Error ? e.message : String(e)}`) + }) + if (!content.trim()) { + throw new Error(`Failed to generate ${path}: AI returned empty content`) } // Push to platform @@ -311,16 +339,23 @@ export async function POST(request: NextRequest) { message: `${pushed} files generated and pushed successfully.`, repoUrl, filesCreated: pushed, + creditsUsed: CREDITS.BUILD_APP_COST, }) } catch (e) { console.error('[build-app] unhandled error:', e) - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ step: 'error', message: 'An unexpected error occurred.' })}\n\n`, - ), - ) + if (charged) { + await refundCredits( + chargedUserId, + CREDITS.BUILD_APP_COST, + 'Build This App failed before completion', + { ...refundMetadata, error: e instanceof Error ? e.message : String(e) }, + ).catch((refundError) => { + console.error('[build-app] failed to refund credits:', refundError) + }) + } + send({ step: 'error', message: e instanceof Error ? e.message : 'Build failed.' }) } finally { - controller.close() + close() } }, }) diff --git a/app/api/generate-scaffold/route.ts b/app/api/generate-scaffold/route.ts index 0dd516c..d1da65e 100644 --- a/app/api/generate-scaffold/route.ts +++ b/app/api/generate-scaffold/route.ts @@ -1,17 +1,21 @@ import { NextRequest, NextResponse } from 'next/server' import { aiConfigErrorMessage, generateWithGateway, isAiConfigured } from '@/lib/ai-gateway' -import { getCreditBalance, deductCredits, CREDITS } from '@/lib/credits' +import { deductCredits, refundCredits, CREDITS } from '@/lib/credits' import { getCurrentUser } from '@/lib/auth' import { getSubscriptionByGithubId, upsertSubscription } from '@/lib/queries' import { hasProAccess } from '@/lib/pro-access' export async function POST(request: NextRequest) { + let chargedUserId = '' + let charged = false + let chargeMetadata: Record = {} + try { if (!isAiConfigured()) { return NextResponse.json({ error: aiConfigErrorMessage() }, { status: 503 }) } - const { appName, description, technologies, existingFiles, missingFiles, userId } = await request.json() + const { appName, description, technologies, existingFiles, missingFiles } = await request.json() const user = await getCurrentUser() if (!user) { return NextResponse.json({ error: 'Sign in with GitHub to generate scaffolds.' }, { status: 401 }) @@ -32,20 +36,23 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }) } - if (userId) { - const currentBalance = await getCreditBalance(userId) - if (currentBalance < CREDITS.SCAFFOLD_COST) { - return NextResponse.json( - { - error: 'Insufficient credits', - required: CREDITS.SCAFFOLD_COST, - available: currentBalance, - message: 'Upgrade to Pro to get unlimited scaffold generation with 3,000 monthly credits.', - }, - { status: 402 }, - ) - } + const deductResult = await deductCredits(user.id, CREDITS.SCAFFOLD_COST, 'scaffold', { + appName, + technologies, + }) + if (!deductResult.success) { + return NextResponse.json( + { + error: deductResult.error ?? 'Insufficient credits', + required: CREDITS.SCAFFOLD_COST, + message: 'Upgrade to Pro to get unlimited scaffold generation with 3,000 monthly credits.', + }, + { status: 402 }, + ) } + charged = true + chargedUserId = user.id + chargeMetadata = { appName, technologies } const raw = await generateWithGateway({ feature: 'scaffold', @@ -119,26 +126,23 @@ Example structure: throw new Error(`Failed to parse scaffold: ${e instanceof Error ? e.message : 'Invalid JSON'}`) } - let creditsUsed = 0 - if (userId) { - const deductResult = await deductCredits(userId, CREDITS.SCAFFOLD_COST, 'scaffold', { - appName, - technologies, - }) - - if (deductResult.success) { - creditsUsed = CREDITS.SCAFFOLD_COST - } - } - return NextResponse.json({ success: true, scaffold, appName, - creditsUsed, + creditsUsed: CREDITS.SCAFFOLD_COST, + creditsRemaining: deductResult.transaction?.balance_after ?? 0, }) } catch (error) { console.error('[scaffold] Generation error:', error) + if (charged) { + await refundCredits(chargedUserId, CREDITS.SCAFFOLD_COST, 'Scaffold generation failed', { + ...chargeMetadata, + error: error instanceof Error ? error.message : String(error), + }).catch((refundError) => { + console.error('[scaffold] Failed to refund credits:', refundError) + }) + } return NextResponse.json( { error: error instanceof Error ? error.message : 'Failed to generate scaffold' }, { status: 500 }, diff --git a/lib/credits.ts b/lib/credits.ts index 34c0da9..54e0bcf 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -59,20 +59,17 @@ function toCount(value: string | number | null | undefined): number { export async function getOrCreateUserCredits(userId: string): Promise { const sql = getDb() - // Try to get existing - const existing = await sql` - SELECT * FROM user_credits WHERE user_id = ${userId} - ` - - if (existing.length > 0) { - return existing[0] as UserCredit - } - - // Create new const result = await sql` - INSERT INTO user_credits (user_id, current_balance, total_granted, total_used) - VALUES (${userId}, 0, 0, 0) - RETURNING * + WITH inserted AS ( + INSERT INTO user_credits (user_id, current_balance, total_granted, total_used) + VALUES (${userId}, 0, 0, 0) + ON CONFLICT (user_id) DO NOTHING + RETURNING * + ) + SELECT * FROM inserted + UNION ALL + SELECT * FROM user_credits WHERE user_id = ${userId} + LIMIT 1 ` return result[0] as UserCredit @@ -100,29 +97,23 @@ export async function grantCredits( metadata: CreditMetadata = {} ): Promise { const sql = getDb() - - // Get or create user credits - const userCredits = await getOrCreateUserCredits(userId) - const newBalance = userCredits.current_balance + amount - - // Update balance - await sql` - UPDATE user_credits - SET - current_balance = ${newBalance}, - total_granted = total_granted + ${amount}, - last_renewal_date = CURRENT_TIMESTAMP - WHERE user_id = ${userId} - ` - - // Record transaction + await getOrCreateUserCredits(userId) + const transaction = await sql` + WITH updated AS ( + UPDATE user_credits + SET + current_balance = current_balance + ${amount}, + total_granted = total_granted + ${amount}, + last_renewal_date = CURRENT_TIMESTAMP + WHERE user_id = ${userId} + RETURNING current_balance + ) INSERT INTO credit_transactions ( user_id, amount, transaction_type, reason, metadata, balance_after ) - VALUES ( - ${userId}, ${amount}, 'grant', ${reason}, ${JSON.stringify(metadata)}::jsonb, ${newBalance} - ) + SELECT ${userId}, ${amount}, 'grant', ${reason}, ${JSON.stringify(metadata)}::jsonb, current_balance + FROM updated RETURNING * ` @@ -137,10 +128,35 @@ export async function renewMonthlyCredits( metadata: CreditMetadata = {} ): Promise { const sql = getDb() - const userCredits = await getOrCreateUserCredits(userId) - const topUpAmount = Math.max(0, monthlyAllowance - userCredits.current_balance) + await getOrCreateUserCredits(userId) - if (topUpAmount === 0) { + const transaction = await sql` + WITH current_row AS ( + SELECT current_balance + FROM user_credits + WHERE user_id = ${userId} + FOR UPDATE + ), + updated AS ( + UPDATE user_credits + SET + current_balance = ${monthlyAllowance}, + total_granted = total_granted + (${monthlyAllowance} - current_row.current_balance), + last_renewal_date = CURRENT_TIMESTAMP + FROM current_row + WHERE user_credits.user_id = ${userId} + AND current_row.current_balance < ${monthlyAllowance} + RETURNING user_credits.current_balance, (${monthlyAllowance} - current_row.current_balance) AS top_up_amount + ) + INSERT INTO credit_transactions ( + user_id, amount, transaction_type, reason, metadata, balance_after + ) + SELECT ${userId}, top_up_amount, 'renewal', ${reason}, ${JSON.stringify(metadata)}::jsonb, current_balance + FROM updated + RETURNING * + ` + + if (transaction.length === 0) { await sql` UPDATE user_credits SET last_renewal_date = CURRENT_TIMESTAMP @@ -149,27 +165,6 @@ export async function renewMonthlyCredits( return null } - const newBalance = userCredits.current_balance + topUpAmount - - await sql` - UPDATE user_credits - SET - current_balance = ${newBalance}, - total_granted = total_granted + ${topUpAmount}, - last_renewal_date = CURRENT_TIMESTAMP - WHERE user_id = ${userId} - ` - - const transaction = await sql` - INSERT INTO credit_transactions ( - user_id, amount, transaction_type, reason, metadata, balance_after - ) - VALUES ( - ${userId}, ${topUpAmount}, 'renewal', ${reason}, ${JSON.stringify(metadata)}::jsonb, ${newBalance} - ) - RETURNING * - ` - return transaction[0] as CreditTransaction } @@ -181,40 +176,33 @@ export async function deductCredits( metadata: CreditMetadata = {} ): Promise<{ success: boolean; transaction?: CreditTransaction; error?: string }> { const sql = getDb() + await getOrCreateUserCredits(userId) - // Get current balance - const userCredits = await getOrCreateUserCredits(userId) - const currentBalance = userCredits.current_balance - - // Check if sufficient balance - if (currentBalance < amount) { - return { - success: false, - error: `Insufficient credits. Required: ${amount}, Available: ${currentBalance}`, - } - } - - const newBalance = currentBalance - amount - - // Update balance - await sql` - UPDATE user_credits - SET - current_balance = ${newBalance}, - total_used = total_used + ${amount} - WHERE user_id = ${userId} - ` - - // Record transaction const transaction = await sql` + WITH updated AS ( + UPDATE user_credits + SET + current_balance = current_balance - ${amount}, + total_used = total_used + ${amount} + WHERE user_id = ${userId} + AND current_balance >= ${amount} + RETURNING current_balance + ) INSERT INTO credit_transactions ( user_id, amount, transaction_type, reason, metadata, balance_after ) - VALUES ( - ${userId}, ${-amount}, ${type}, ${`${type} deduction`}, ${JSON.stringify(metadata)}::jsonb, ${newBalance} - ) + SELECT ${userId}, ${-amount}, ${type}, ${`${type} deduction`}, ${JSON.stringify(metadata)}::jsonb, current_balance + FROM updated RETURNING * ` + + if (transaction.length === 0) { + const currentBalance = await getCreditBalance(userId) + return { + success: false, + error: `Insufficient credits. Required: ${amount}, Available: ${currentBalance}`, + } + } return { success: true, @@ -230,26 +218,20 @@ export async function refundCredits( metadata: CreditMetadata = {} ): Promise { const sql = getDb() - - // Get or create user credits - const userCredits = await getOrCreateUserCredits(userId) - const newBalance = userCredits.current_balance + amount - - // Update balance - await sql` - UPDATE user_credits - SET current_balance = ${newBalance} - WHERE user_id = ${userId} - ` - - // Record transaction + await getOrCreateUserCredits(userId) + const transaction = await sql` + WITH updated AS ( + UPDATE user_credits + SET current_balance = current_balance + ${amount} + WHERE user_id = ${userId} + RETURNING current_balance + ) INSERT INTO credit_transactions ( user_id, amount, transaction_type, reason, metadata, balance_after ) - VALUES ( - ${userId}, ${amount}, 'refund', ${reason}, ${JSON.stringify(metadata)}::jsonb, ${newBalance} - ) + SELECT ${userId}, ${amount}, 'refund', ${reason}, ${JSON.stringify(metadata)}::jsonb, current_balance + FROM updated RETURNING * `