From ea9e919e541872e68489984018417c3a5e377097 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 27 Jun 2026 11:13:20 +0000 Subject: [PATCH 1/2] Fix critical auth billing and data loss regressions Co-authored-by: Cole Collins --- app/api/analyses/[id]/analyze/route.ts | 48 ++++--- app/api/analyses/[id]/run/route.ts | 32 +++-- app/api/analyze/route.ts | 6 + app/api/build-app/route.ts | 120 ++++++++++-------- app/api/code-completion/route.ts | 38 ++++-- app/api/generate-scaffold/route.ts | 51 ++++---- .../[id]/milestones/[milestoneId]/route.ts | 9 +- lib/credits.ts | 91 ++++++------- lib/queries.ts | 80 ++++++++---- 9 files changed, 283 insertions(+), 192 deletions(-) diff --git a/app/api/analyses/[id]/analyze/route.ts b/app/api/analyses/[id]/analyze/route.ts index 65e9bec..a4269a2 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, refundCredits, CREDITS } from '@/lib/credits' +import { getCurrentUser } from '@/lib/auth' const model = 'openai/gpt-4-turbo' @@ -20,30 +21,36 @@ interface AppSuggestion { } export async function POST(request: NextRequest) { + let chargedUserId: string | null = null + let chargeMetadata: Record = {} + try { - const { analysisId, selectedRepos, userId } = (await request.json()) as { + const { analysisId, selectedRepos } = (await request.json()) as { analysisId: string selectedRepos: SelectedRepository[] - userId: string } - // Check credit balance before proceeding - if (!userId) { - return NextResponse.json({ error: 'User ID required' }, { status: 401 }) + const user = await getCurrentUser() + if (!user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const currentBalance = await getCreditBalance(userId) - if (currentBalance < CREDITS.ANALYSIS_COST) { + chargeMetadata = { + analysisId, + selectedRepos: selectedRepos.map((r) => r.name), + } + const creditResult = await deductCredits(user.id, CREDITS.ANALYSIS_COST, 'analysis', chargeMetadata) + if (!creditResult.success) { return NextResponse.json( { - error: 'Insufficient credits', + error: creditResult.error || 'Insufficient credits', required: CREDITS.ANALYSIS_COST, - available: currentBalance, message: 'Upgrade to Pro to get unlimited analyses with 3,000 monthly credits.', }, { status: 402 } ) } + chargedUserId = user.id // Get all repo files from database const filesByRepo: Record = {} @@ -94,21 +101,7 @@ Return as JSON array of app suggestions. Focus on practical, buildable applicati console.error('Failed to parse AI response:', e) } - // Deduct credits for successful analysis - const deductResult = await deductCredits(userId, 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 } - ) - } - - const newBalance = deductResult.transaction?.balance_after || 0 + const newBalance = creditResult.transaction?.balance_after || 0 return NextResponse.json({ analysisId, @@ -119,6 +112,11 @@ Return as JSON array of app suggestions. Focus on practical, buildable applicati creditsRemaining: newBalance, }) } catch (error) { + if (chargedUserId) { + await refundCredits(chargedUserId, CREDITS.ANALYSIS_COST, 'Legacy analysis failed', chargeMetadata).catch( + (refundError) => console.error('Failed to refund analysis credits:', refundError), + ) + } console.error('Analysis error:', error) return NextResponse.json({ error: 'Failed to analyze repositories' }, { status: 500 }) } diff --git a/app/api/analyses/[id]/run/route.ts b/app/api/analyses/[id]/run/route.ts index 11a4675..8a78d25 100644 --- a/app/api/analyses/[id]/run/route.ts +++ b/app/api/analyses/[id]/run/route.ts @@ -13,7 +13,8 @@ import { updateAnalysisStatus, createRepoFile, createBlueprint, - deleteBlueprintsByAnalysis, + deleteBlueprintsByAnalysisExcept, + deleteBlueprintsByIds, getBlueprintsByAnalysis, getSubscriptionByGithubId, upsertSubscription, @@ -144,6 +145,7 @@ export async function POST( const send = (data: object) => { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) } + const replacementBlueprintIds: string[] = [] try { const accessToken = await getCurrentAccessToken() @@ -169,7 +171,12 @@ export async function POST( if (!sub) { sub = await upsertSubscription({ github_id: user.github_id }).catch(() => null) } - if (isOnFreeTier(user, sub) && sub) { + if (!sub) { + send({ error: 'Unable to initialize billing usage. Please try again.', status: 'failed' }) + controller.close() + return + } + if (isOnFreeTier(user, sub)) { const limit = PLANS.free.analyses_per_month if (sub.analyses_used_this_month >= limit) { send({ error: `You've reached your free plan limit of ${limit} analyses per month. Upgrade to Pro for unlimited analyses.`, status: 'failed' }) @@ -200,7 +207,6 @@ export async function POST( // Update status to scanning await updateAnalysisStatus(id, 'scanning') - await deleteBlueprintsByAnalysis(id) send({ status: 'scanning', progress: 10 }) // Fetch file trees from GitHub for each repository @@ -393,7 +399,12 @@ For each app blueprint: // Check if response was truncated (hit max_tokens) if (aiResponse.stop_reason === 'max_tokens') { + const msg = 'AI response was cut short (output too large). Try running with fewer repositories selected.' console.warn('[analysis] AI response truncated (max_tokens). Tool output may be incomplete.') + send({ status: 'failed', error: msg }) + await updateAnalysisStatus(id, 'failed', { error_message: msg }) + controller.close() + return } // Extract structured output from tool use response @@ -431,7 +442,7 @@ For each app blueprint: .sort((a, b) => getOpportunityScore(b) - getOpportunityScore(a)) for (const bp of rankedBlueprints) { - await createBlueprint({ + const created = await createBlueprint({ analysis_id: id, user_id: user.id, name: bp.name.slice(0, 255), @@ -445,16 +456,16 @@ For each app blueprint: technologies: bp.technologies, ai_explanation: bp.explanation, }) + replacementBlueprintIds.push(created.id) } } + await incrementAnalysisUsage(user.github_id) + await deleteBlueprintsByAnalysisExcept(id, replacementBlueprintIds) + // Update to complete await updateAnalysisStatus(id, 'complete', { analyzed_files: allFiles.length }) - await incrementAnalysisUsage(user.github_id).catch((e) => - console.error('[analysis] Failed to increment usage:', e) - ) - // Get final blueprints and hydrate recurring-value surfaces from them. const finalBlueprints = await getBlueprintsByAnalysis(id, user.id) for (const blueprint of finalBlueprints) { @@ -468,6 +479,11 @@ For each app blueprint: } catch (error) { console.error('Analysis error:', error) const errorDetail = error instanceof Error ? error.message : 'Unknown error' + try { + await deleteBlueprintsByIds(replacementBlueprintIds) + } catch (dbErr) { + console.error('Failed to clean up partial replacement blueprints:', dbErr) + } try { await updateAnalysisStatus(id, 'failed', { error_message: errorDetail }) } catch (dbErr) { diff --git a/app/api/analyze/route.ts b/app/api/analyze/route.ts index 2f74f8c..6437dc5 100644 --- a/app/api/analyze/route.ts +++ b/app/api/analyze/route.ts @@ -2,9 +2,15 @@ import { NextResponse } from 'next/server' import { generateText } from 'ai' import { scanCrossPlatformCode } from '@/lib/cross-platform-scanner' import { analyzeScannedFiles, createAnthropicPromptRunner } from '@/lib/repofuse-core.js' +import { getCurrentUser } from '@/lib/auth' export async function POST() { try { + const user = await getCurrentUser() + if (!user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const scannedFiles = await scanCrossPlatformCode() if (scannedFiles.length === 0) { diff --git a/app/api/build-app/route.ts b/app/api/build-app/route.ts index 45c0852..9d4f7c2 100644 --- a/app/api/build-app/route.ts +++ b/app/api/build-app/route.ts @@ -3,6 +3,7 @@ import { generateWithGateway } from '@/lib/ai-gateway' import { getCurrentUser } from '@/lib/auth' import { getSubscriptionByGithubId, upsertSubscription, type AppBlueprint } from '@/lib/queries' import { hasProAccess } from '@/lib/pro-access' +import { CREDITS, deductCredits, refundCredits } from '@/lib/credits' type Platform = 'github' | 'gitlab' @@ -56,20 +57,20 @@ 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 files = new Map() // Missing files from the blueprint for (const f of blueprint.missing_files) { - files.push({ path: f.name, purpose: f.purpose }) + if (!files.has(f.name)) files.set(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' }) + if (!files.has('README.md')) files.set('README.md', 'Comprehensive setup, usage, and API documentation') + if (!files.has('package.json')) files.set('package.json', 'Project dependencies and scripts for the tech stack') + if (!files.has('.env.example')) files.set('.env.example', 'All required environment variables with placeholder values') + if (!files.has('.gitignore')) files.set('.gitignore', 'Gitignore file appropriate for this stack') - return files + return Array.from(files, ([path, purpose]) => ({ path, purpose })) } async function createGitHubRepo( @@ -88,7 +89,7 @@ async function createGitHubRepo( body: JSON.stringify({ name: repoName, description, - private: false, + private: true, auto_init: false, }), }) @@ -128,7 +129,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(`Failed to push ${path}: ${err.message ?? 'GitHub API error'}`) } } @@ -190,7 +191,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(`Failed to push ${path} to GitLab: ${err.message ?? 'GitLab API error'}`) } } @@ -201,12 +202,22 @@ export async function POST(request: NextRequest) { const send = (data: object) => { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) } + let isClosed = false + const close = () => { + if (!isClosed) { + controller.close() + isClosed = true + } + } + let chargedUserId: string | null = null + let buildCompleted = false + let chargeMetadata: Record = {} try { const user = await getCurrentUser() if (!user) { send({ step: 'error', message: 'Sign in before building an app.' }) - controller.close() + close() return } @@ -216,7 +227,7 @@ 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() + close() return } @@ -225,11 +236,20 @@ export async function POST(request: NextRequest) { if (!repoName?.trim()) { send({ step: 'error', message: 'Repository name is required.' }) - controller.close() + close() return } const cleanRepoName = repoName.trim().replace(/\s+/g, '-').toLowerCase() + chargeMetadata = { platform, repoName: cleanRepoName, blueprintName: blueprint.name } + + const creditResult = await deductCredits(user.id, CREDITS.BUILD_APP_COST, 'build_app', chargeMetadata) + if (!creditResult.success) { + send({ step: 'error', message: creditResult.error || 'Insufficient credits.' }) + close() + return + } + chargedUserId = user.id // Step 1 — determine files to generate const filesToGenerate = getFilesToGenerate(blueprint) @@ -245,31 +265,22 @@ export async function POST(request: NextRequest) { let gitlabProjectId: number | null = null let gitlabBranch = 'main' - try { - if (platform === 'github') { - repoUrl = await createGitHubRepo( - accessToken, - user.github_username, - cleanRepoName, - blueprint.description ?? blueprint.name, - ) - } else { - const project = await createGitLabProject( - accessToken, - cleanRepoName, - blueprint.description ?? blueprint.name, - ) - repoUrl = project.web_url - gitlabProjectId = project.id - 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 + if (platform === 'github') { + repoUrl = await createGitHubRepo( + accessToken, + user.github_username, + cleanRepoName, + blueprint.description ?? blueprint.name, + ) + } else { + const project = await createGitLabProject( + accessToken, + cleanRepoName, + blueprint.description ?? blueprint.name, + ) + repoUrl = project.web_url + gitlabProjectId = project.id + gitlabBranch = project.default_branch || 'main' } send({ step: 'repo_created', message: 'Repository created. Generating and pushing files…', repoUrl }) @@ -280,12 +291,9 @@ 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) + if (!content.trim()) { + throw new Error(`Generated empty content for ${path}`) } // Push to platform @@ -306,21 +314,33 @@ export async function POST(request: NextRequest) { }) } + buildCompleted = true send({ step: 'done', 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 (chargedUserId && !buildCompleted) { + await refundCredits(chargedUserId, CREDITS.BUILD_APP_COST, 'Build app failed', chargeMetadata).catch( + (refundError) => console.error('[build-app] Failed to refund credits:', refundError), + ) + } + if (!isClosed) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + step: 'error', + message: e instanceof Error ? e.message : 'An unexpected error occurred.', + })}\n\n`, + ), + ) + } } finally { - controller.close() + close() } }, }) diff --git a/app/api/code-completion/route.ts b/app/api/code-completion/route.ts index f7ede13..9e402e1 100644 --- a/app/api/code-completion/route.ts +++ b/app/api/code-completion/route.ts @@ -5,6 +5,9 @@ import { batchGenerateCompletions, } from '@/lib/code-completion' import { getDb } from '@/lib/db' +import { getCurrentUser } from '@/lib/auth' + +const MAX_BATCH_COMPLETIONS = 10 const LANG_EXTENSIONS: Record = { python: ['.py'], @@ -24,18 +27,20 @@ const LANG_EXTENSIONS: Record = { shell: ['.sh', '.bash'], } -async function fetchSnippetsFromDb(language: string): Promise { +async function fetchSnippetsFromDb(language: string, userId: string): Promise { try { const sql = getDb() const extensions = LANG_EXTENSIONS[language.toLowerCase()] ?? [] if (extensions.length === 0) return [] const rows = await sql` - SELECT id, path, name, extension, ai_summary, reusability_score, exports, imports - FROM repo_files - WHERE extension = ANY(${extensions}::text[]) - AND ai_summary IS NOT NULL - ORDER BY reusability_score DESC + SELECT rf.id, rf.path, rf.name, rf.extension, rf.ai_summary, rf.reusability_score, rf.exports, rf.imports + FROM repo_files rf + JOIN repositories r ON r.id = rf.repository_id + WHERE r.user_id = ${userId} + AND rf.extension = ANY(${extensions}::text[]) + AND rf.ai_summary IS NOT NULL + ORDER BY rf.reusability_score DESC LIMIT 20 ` @@ -63,6 +68,11 @@ async function fetchSnippetsFromDb(language: string): Promise { export async function POST(request: NextRequest) { try { + const user = await getCurrentUser() + if (!user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const body = await request.json() const { incompleteCode, codebaseSnippets = [], language = 'python' } = body @@ -75,7 +85,7 @@ export async function POST(request: NextRequest) { const snippets: CodeSnippet[] = codebaseSnippets.length > 0 ? codebaseSnippets - : await fetchSnippetsFromDb(language) + : await fetchSnippetsFromDb(language, user.id) const result = await generateRelevanceGuidedCompletion( incompleteCode, @@ -120,6 +130,11 @@ export async function POST(request: NextRequest) { */ export async function PUT(request: NextRequest) { try { + const user = await getCurrentUser() + if (!user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const body = await request.json() const { codeSnippets = [], codebaseSnippets = [], language = 'python' } = body @@ -130,9 +145,16 @@ export async function PUT(request: NextRequest) { ) } + if (codeSnippets.length > MAX_BATCH_COMPLETIONS) { + return NextResponse.json( + { error: `codeSnippets is limited to ${MAX_BATCH_COMPLETIONS} items per request` }, + { status: 400 } + ) + } + const snippets: CodeSnippet[] = codebaseSnippets.length > 0 ? codebaseSnippets - : await fetchSnippetsFromDb(language) + : await fetchSnippetsFromDb(language, user.id) const results = await batchGenerateCompletions( codeSnippets, diff --git a/app/api/generate-scaffold/route.ts b/app/api/generate-scaffold/route.ts index 312c934..7d2201a 100644 --- a/app/api/generate-scaffold/route.ts +++ b/app/api/generate-scaffold/route.ts @@ -1,17 +1,20 @@ 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: string | null = null + 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 +35,19 @@ 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 }, - ) - } + chargeMetadata = { appName, technologies } + const creditResult = await deductCredits(user.id, CREDITS.SCAFFOLD_COST, 'scaffold', chargeMetadata) + if (!creditResult.success) { + return NextResponse.json( + { + error: creditResult.error || 'Insufficient credits', + required: CREDITS.SCAFFOLD_COST, + message: 'Upgrade to Pro to get scaffold generation with 3,000 monthly credits.', + }, + { status: 402 }, + ) } + chargedUserId = user.id const raw = await generateWithGateway({ feature: 'scaffold', @@ -120,25 +122,18 @@ 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, }) } catch (error) { + if (chargedUserId) { + await refundCredits(chargedUserId, CREDITS.SCAFFOLD_COST, 'Scaffold generation failed', chargeMetadata).catch( + (refundError) => console.error('[scaffold] Failed to refund credits:', refundError), + ) + } console.error('[scaffold] Generation error:', error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Failed to generate scaffold' }, diff --git a/app/api/projects/[id]/milestones/[milestoneId]/route.ts b/app/api/projects/[id]/milestones/[milestoneId]/route.ts index 0b04cb1..f75fd26 100644 --- a/app/api/projects/[id]/milestones/[milestoneId]/route.ts +++ b/app/api/projects/[id]/milestones/[milestoneId]/route.ts @@ -9,10 +9,10 @@ export async function PATCH( const user = await getCurrentUser() if (!user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - const { milestoneId } = await params + const { id, milestoneId } = await params try { const { completed } = await request.json() - const milestone = await toggleMilestone(milestoneId, Boolean(completed)) + const milestone = await toggleMilestone(milestoneId, id, user.id, Boolean(completed)) if (!milestone) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json({ milestone }) } catch (err) { @@ -28,9 +28,10 @@ export async function DELETE( const user = await getCurrentUser() if (!user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - const { milestoneId } = await params + const { id, milestoneId } = await params try { - await deleteMilestone(milestoneId) + const deleted = await deleteMilestone(milestoneId, id, user.id) + if (!deleted) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json({ ok: true }) } catch (err) { console.error('[milestones/id] DELETE error:', err) diff --git a/lib/credits.ts b/lib/credits.ts index 34c0da9..49292b7 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -181,41 +181,41 @@ export async function deductCredits( metadata: CreditMetadata = {} ): Promise<{ success: boolean; transaction?: CreditTransaction; error?: string }> { const sql = getDb() - - // 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 + + await getOrCreateUserCredits(userId) + 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, transaction: transaction[0] as CreditTransaction, @@ -230,26 +230,27 @@ 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 * ` diff --git a/lib/queries.ts b/lib/queries.ts index 7dd365e..99a435a 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -144,30 +144,21 @@ export async function upsertSubscription(data: { return result[0] as Subscription } catch (error) { console.error('[v0] Error upserting subscription:', error) - // Return a default subscription object if the operation fails - return { - id: '', - github_id: data.github_id, - stripe_customer_id: data.stripe_customer_id ?? null, - stripe_subscription_id: data.stripe_subscription_id ?? null, - plan: data.plan ?? 'free', - status: data.status ?? 'active', - current_period_end: data.current_period_end ?? null, - analyses_used_this_month: 0, - billing_cycle_anchor: new Date().toISOString(), - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - } as Subscription + throw error } } export async function incrementAnalysisUsage(githubId: number): Promise { const sql = getDb() - await sql` + const result = await sql` UPDATE subscriptions SET analyses_used_this_month = analyses_used_this_month + 1, updated_at = CURRENT_TIMESTAMP WHERE github_id = ${githubId} + RETURNING id ` + if (result.length === 0) { + throw new Error(`No subscription row found for GitHub user ${githubId}`) + } } export async function resetMonthlyUsage(githubId: number): Promise { @@ -380,6 +371,25 @@ export async function deleteBlueprintsByAnalysis(analysisId: string): Promise { + const sql = getDb() + if (keepIds.length === 0) { + await deleteBlueprintsByAnalysis(analysisId) + return + } + await sql` + DELETE FROM app_blueprints + WHERE analysis_id = ${analysisId} + AND NOT (id = ANY(${keepIds}::uuid[])) + ` +} + +export async function deleteBlueprintsByIds(ids: string[]): Promise { + if (ids.length === 0) return + const sql = getDb() + await sql`DELETE FROM app_blueprints WHERE id = ANY(${ids}::uuid[])` +} + export async function updateUserBilling(userId: string, data: UserBillingUpdate): Promise { const sql = getDb() await sql` @@ -1044,27 +1054,49 @@ export async function createMilestone(data: { return result[0] as ProjectMilestone } -export async function toggleMilestone(id: string, completed: boolean): Promise { +export async function toggleMilestone( + id: string, + projectId: string, + userId: string, + completed: boolean, +): Promise { const sql = getDb() const result = completed ? await sql` - UPDATE project_milestones + UPDATE project_milestones AS m SET completed = true, completed_at = NOW() - WHERE id = ${id} - RETURNING * + FROM projects AS p + WHERE m.id = ${id} + AND m.project_id = ${projectId} + AND p.id = m.project_id + AND p.user_id = ${userId} + RETURNING m.* ` : await sql` - UPDATE project_milestones + UPDATE project_milestones AS m SET completed = false, completed_at = NULL - WHERE id = ${id} - RETURNING * + FROM projects AS p + WHERE m.id = ${id} + AND m.project_id = ${projectId} + AND p.id = m.project_id + AND p.user_id = ${userId} + RETURNING m.* ` return (result[0] as ProjectMilestone) || null } -export async function deleteMilestone(id: string): Promise { +export async function deleteMilestone(id: string, projectId: string, userId: string): Promise { const sql = getDb() - await sql`DELETE FROM project_milestones WHERE id = ${id}` + const result = await sql` + DELETE FROM project_milestones AS m + USING projects AS p + WHERE m.id = ${id} + AND m.project_id = ${projectId} + AND p.id = m.project_id + AND p.user_id = ${userId} + RETURNING m.id + ` + return result.length > 0 } export async function seedDefaultMilestones(projectId: string): Promise { From d742e810cdd3abfa5f7803f2002a68f683384298 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 27 Jun 2026 11:14:12 +0000 Subject: [PATCH 2/2] Fix analysis truncation type guard Co-authored-by: Cole Collins --- app/api/analyses/[id]/run/route.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/app/api/analyses/[id]/run/route.ts b/app/api/analyses/[id]/run/route.ts index 8a78d25..a28aa57 100644 --- a/app/api/analyses/[id]/run/route.ts +++ b/app/api/analyses/[id]/run/route.ts @@ -422,12 +422,9 @@ For each app blueprint: const blueprintsFromAI = parseBlueprints(rawInput) if (blueprintsFromAI.length === 0) { - const wasMaxTokens = aiResponse.stop_reason === 'max_tokens' - const msg = wasMaxTokens - ? 'AI response was cut short (output too large). Try running with fewer repositories selected.' - : rawInput - ? 'AI returned empty results. Try running the analysis again — this can happen intermittently.' - : 'Model did not return usable blueprints (missing tool output). Check ANTHROPIC_API_KEY and model availability.' + const msg = rawInput + ? 'AI returned empty results. Try running the analysis again — this can happen intermittently.' + : 'Model did not return usable blueprints (missing tool output). Check ANTHROPIC_API_KEY and model availability.' console.error('[analysis] No valid blueprints.', { stop_reason: aiResponse.stop_reason, rawInput: JSON.stringify(rawInput).slice(0, 500) }) send({ status: 'failed', error: msg }) await updateAnalysisStatus(id, 'failed', { error_message: msg })