From 959bab1c53d4bf06d8433f5390d4f8455b55efab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 11:13:20 +0000 Subject: [PATCH 1/2] Fix critical auth and billing regressions Co-authored-by: Cole Collins --- app/api/analyses/[id]/analyze/route.ts | 118 ++++++------- app/api/analyses/[id]/run/route.ts | 62 +++---- app/api/app-idea-chat/route.ts | 160 ++++++++++-------- app/api/build-app/route.ts | 59 +++++-- app/api/code-completion/route.ts | 36 +++- app/api/generate-scaffold/route.ts | 116 ++++++------- app/api/pattern-analyzer/route.ts | 69 +++++--- .../[id]/milestones/[milestoneId]/route.ts | 8 +- lib/credits.ts | 81 ++++----- lib/queries.ts | 98 ++++++++++- scripts/critical-regression-check.mjs | 159 +++++++++++++++++ 11 files changed, 639 insertions(+), 327 deletions(-) create mode 100644 scripts/critical-regression-check.mjs diff --git a/app/api/analyses/[id]/analyze/route.ts b/app/api/analyses/[id]/analyze/route.ts index 65e9bec..d140944 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' @@ -21,41 +22,48 @@ interface AppSuggestion { export async function POST(request: NextRequest) { try { - const { analysisId, selectedRepos, userId } = (await request.json()) as { + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + 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 }) + if (!Array.isArray(selectedRepos) || selectedRepos.length === 0) { + return NextResponse.json({ error: 'At least one repository is required' }, { status: 400 }) } - const currentBalance = await getCreditBalance(userId) - if (currentBalance < CREDITS.ANALYSIS_COST) { + const creditMetadata = { + analysisId, + selectedRepos: selectedRepos.map((r) => r.name), + } + const deductResult = await deductCredits(user.id, CREDITS.ANALYSIS_COST, 'analysis', creditMetadata) + if (!deductResult.success) { return NextResponse.json( { - error: 'Insufficient credits', + error: deductResult.error || 'Insufficient credits', required: CREDITS.ANALYSIS_COST, - available: currentBalance, message: 'Upgrade to Pro to get unlimited analyses with 3,000 monthly credits.', }, { status: 402 } ) } - // Get all repo files from database - const filesByRepo: Record = {} - - for (const repo of selectedRepos) { - // Fetch repo structure from GitHub API - const files = await fetchRepoStructure(repo) - filesByRepo[repo.name] = files - } + try { + // Get all repo files from database + const filesByRepo: Record = {} + + for (const repo of selectedRepos) { + // Fetch repo structure from GitHub API + const files = await fetchRepoStructure(repo) + filesByRepo[repo.name] = files + } - // Use AI to analyze cross-repo patterns - const prompt = `You are an expert software architect analyzing code across multiple repositories. + // Use AI to analyze cross-repo patterns + const prompt = `You are an expert software architect analyzing code across multiple repositories. Given these repositories with their file structures: ${Object.entries(filesByRepo).map(([name, files]) => @@ -76,48 +84,40 @@ Your task is to discover what applications could be built by combining files fro Return as JSON array of app suggestions. Focus on practical, buildable applications.` - const result = await generateText({ - model, - prompt, - temperature: 0.7, - maxOutputTokens: 2000, - }) - - // Parse AI response and save suggestions - let suggestions: AppSuggestion[] = [] - try { - const jsonMatch = result.text.match(/\[[\s\S]*\]/) - if (jsonMatch) { - suggestions = JSON.parse(jsonMatch[0]) + const result = await generateText({ + model, + prompt, + temperature: 0.7, + maxOutputTokens: 2000, + }) + + // Parse AI response and save suggestions + let suggestions: AppSuggestion[] = [] + try { + const jsonMatch = result.text.match(/\[[\s\S]*\]/) + if (jsonMatch) { + suggestions = JSON.parse(jsonMatch[0]) + } + } catch (e) { + console.error('Failed to parse AI response:', e) } - } catch (e) { - 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 + + return NextResponse.json({ + analysisId, + suggestions, + totalSuggestions: suggestions.length, + completeSuggestions: suggestions.filter((suggestion) => suggestion.is_complete).length, + creditsUsed: CREDITS.ANALYSIS_COST, + creditsRemaining: newBalance, + }) + } catch (error) { + await refundCredits(user.id, CREDITS.ANALYSIS_COST, 'legacy analysis failed', creditMetadata).catch((refundError) => { + console.error('Failed to refund analysis credits:', refundError) + }) + throw error } - - const newBalance = deductResult.transaction?.balance_after || 0 - - return NextResponse.json({ - analysisId, - suggestions, - totalSuggestions: suggestions.length, - completeSuggestions: suggestions.filter((suggestion) => suggestion.is_complete).length, - creditsUsed: CREDITS.ANALYSIS_COST, - creditsRemaining: newBalance, - }) } catch (error) { 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..334a495 100644 --- a/app/api/analyses/[id]/run/route.ts +++ b/app/api/analyses/[id]/run/route.ts @@ -12,8 +12,7 @@ import { getRepositoriesForAnalysis, updateAnalysisStatus, createRepoFile, - createBlueprint, - deleteBlueprintsByAnalysis, + replaceBlueprintsForAnalysis, getBlueprintsByAnalysis, getSubscriptionByGithubId, upsertSubscription, @@ -169,7 +168,12 @@ export async function POST( if (!sub) { sub = await upsertSubscription({ github_id: user.github_id }).catch(() => null) } - if (isOnFreeTier(user, sub) && sub) { + if (isOnFreeTier(user, sub)) { + if (!sub) { + send({ error: 'Could not verify your free plan usage. Please try again later.', status: 'failed' }) + controller.close() + return + } 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' }) @@ -198,9 +202,8 @@ export async function POST( return } - // Update status to scanning + // Update status to scanning. Keep existing blueprints visible until replacements are ready. await updateAnalysisStatus(id, 'scanning') - await deleteBlueprintsByAnalysis(id) send({ status: 'scanning', progress: 10 }) // Fetch file trees from GitHub for each repository @@ -424,36 +427,33 @@ For each app blueprint: return } - // Save blueprints to database - { - const rankedBlueprints = blueprintsFromAI - .map((bp) => normalizeBlueprint(bp)) - .sort((a, b) => getOpportunityScore(b) - getOpportunityScore(a)) - - for (const bp of rankedBlueprints) { - await createBlueprint({ - analysis_id: id, - user_id: user.id, - name: bp.name.slice(0, 255), - description: bp.description, - app_type: bp.app_type?.slice(0, 100) ?? null, - complexity: bp.complexity, - reuse_percentage: bp.reuse_percentage, - existing_files: bp.existing_files, - missing_files: bp.missing_files, - estimated_effort: getEffortEstimate(bp.complexity, bp.missing_files.length), - technologies: bp.technologies, - ai_explanation: bp.explanation, - }) - } - } + const rankedBlueprints = blueprintsFromAI + .map((bp) => normalizeBlueprint(bp)) + .sort((a, b) => getOpportunityScore(b) - getOpportunityScore(a)) + + await replaceBlueprintsForAnalysis( + id, + user.id, + rankedBlueprints.map((bp) => ({ + name: bp.name.slice(0, 255), + description: bp.description, + app_type: bp.app_type?.slice(0, 100) ?? null, + complexity: bp.complexity, + reuse_percentage: bp.reuse_percentage, + existing_files: bp.existing_files, + missing_files: bp.missing_files, + estimated_effort: getEffortEstimate(bp.complexity, bp.missing_files.length), + technologies: bp.technologies, + ai_explanation: bp.explanation, + })), + ) // 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) - ) + if (isOnFreeTier(user, sub)) { + await incrementAnalysisUsage(user.github_id) + } // Get final blueprints and hydrate recurring-value surfaces from them. const finalBlueprints = await getBlueprintsByAnalysis(id, user.id) diff --git a/app/api/app-idea-chat/route.ts b/app/api/app-idea-chat/route.ts index 64b439e..5865380 100644 --- a/app/api/app-idea-chat/route.ts +++ b/app/api/app-idea-chat/route.ts @@ -6,7 +6,7 @@ import { getFilesByRepository, } from '@/lib/queries' import { getCurrentUser } from '@/lib/auth' -import { deductCredits, CREDITS } from '@/lib/credits' +import { deductCredits, refundCredits, CREDITS } from '@/lib/credits' import type { AppIdeaChatResponse, ChatMessage } from '@/lib/app-idea-chat-types' import { aiConfigErrorMessage, generateWithGateway, isAiConfigured } from '@/lib/ai-gateway' @@ -80,57 +80,48 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Message is required' }, { status: 400 }) } - if (!process.env.ANTHROPIC_API_KEY) { - return NextResponse.json( - { error: 'App Idea Chat is not configured. Missing ANTHROPIC_API_KEY.' }, - { status: 503 }, - ) - } - - const creditResult = await deductCredits( - user.id, - CREDITS.PATTERN_ANALYZER_COST, - 'pattern_analyzer', - { analysisId }, - ) - if (!creditResult.success) { - return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 }) - } - let codebaseContext = '' if (analysisId) { - try { - const analysis = await getAnalysisById(analysisId, user.id) - if (analysis && analysis.status === 'complete') { - const [repositories, blueprints] = await Promise.all([ - getRepositoriesForAnalysis(analysisId, user.id), - getBlueprintsByAnalysis(analysisId, user.id), - ]) - - const allFiles = ( - await Promise.all(repositories.map((r) => getFilesByRepository(r.id))) - ).flat() - - const techCount: Record = {} - for (const file of allFiles) { - for (const tech of file.technologies) { - techCount[tech] = (techCount[tech] || 0) + 1 - } - } - const topTech = Object.entries(techCount) - .sort((a, b) => b[1] - a[1]) - .slice(0, 10) - .map(([t]) => t) - const reusableFiles = allFiles - .sort((a, b) => b.reusability_score - a.reusability_score) - .slice(0, 16) - .map((file) => { - const repo = repositories.find((r) => r.id === file.repository_id) - const purpose = file.purpose?.trim() - return `${repo?.full_name ?? 'repo'}/${file.path}${purpose ? ` - ${purpose}` : ''}` - }) - - codebaseContext = ` + const analysis = await getAnalysisById(analysisId, user.id) + if (!analysis) { + return NextResponse.json({ error: 'Analysis not found' }, { status: 404 }) + } + if (analysis.status !== 'complete') { + return NextResponse.json( + { error: 'Analysis must be complete before app idea chat can use it.' }, + { status: 422 }, + ) + } + + const [repositories, blueprints] = await Promise.all([ + getRepositoriesForAnalysis(analysisId, user.id), + getBlueprintsByAnalysis(analysisId, user.id), + ]) + + const allFiles = ( + await Promise.all(repositories.map((r) => getFilesByRepository(r.id))) + ).flat() + + const techCount: Record = {} + for (const file of allFiles) { + for (const tech of file.technologies) { + techCount[tech] = (techCount[tech] || 0) + 1 + } + } + const topTech = Object.entries(techCount) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([t]) => t) + const reusableFiles = allFiles + .sort((a, b) => b.reusability_score - a.reusability_score) + .slice(0, 16) + .map((file) => { + const repo = repositories.find((r) => r.id === file.repository_id) + const purpose = file.purpose?.trim() + return `${repo?.full_name ?? 'repo'}/${file.path}${purpose ? ` - ${purpose}` : ''}` + }) + + codebaseContext = ` ## Developer's codebase context Repositories: ${repositories.map((r) => r.name).join(', ')} Top technologies: ${topTech.join(', ')} @@ -139,10 +130,17 @@ Existing blueprints: ${blueprints.slice(0, 5).map((b) => b.name).join(', ') || ' Reusable source files: ${reusableFiles.length > 0 ? reusableFiles.map((file) => `- ${file}`).join('\n') : '- No analyzed file summaries yet'} ` - } - } catch { - // Codebase context optional — continue without it - } + } + + const creditMetadata = { analysisId } + const creditResult = await deductCredits( + user.id, + CREDITS.PATTERN_ANALYZER_COST, + 'pattern_analyzer', + creditMetadata, + ) + if (!creditResult.success) { + return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 }) } const conversationHistory = normalizeConversationHistory(history).slice(-6) @@ -186,30 +184,42 @@ Always respond with valid JSON only (no markdown fences): { role: 'user', content: message.trim() }, ]) - const raw = await generateWithGateway({ - system: systemPrompt, - messages, - maxOutputTokens: 2048, - userId: user.id, - feature: 'app-idea-chat', - }) - - let parsed: AppIdeaChatResponse try { - parsed = parseAppIdeaChatResponse(raw) - } catch { - return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 }) - } + const raw = await generateWithGateway({ + system: systemPrompt, + messages, + maxOutputTokens: 2048, + userId: user.id, + feature: 'app-idea-chat', + }) + + let parsed: AppIdeaChatResponse + try { + parsed = parseAppIdeaChatResponse(raw) + } catch { + throw new Error('Failed to parse AI response') + } - if (!parsed.reply?.trim()) { - return NextResponse.json({ error: 'Empty AI response' }, { status: 500 }) - } + if (!parsed.reply?.trim()) { + throw new Error('Empty AI response') + } - return NextResponse.json({ - reply: parsed.reply, - suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions : [], - followUpQuestions: Array.isArray(parsed.followUpQuestions) ? parsed.followUpQuestions : [], - }) + return NextResponse.json({ + reply: parsed.reply, + suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions : [], + followUpQuestions: Array.isArray(parsed.followUpQuestions) ? parsed.followUpQuestions : [], + }) + } catch (error) { + await refundCredits( + user.id, + CREDITS.PATTERN_ANALYZER_COST, + 'app idea chat failed', + creditMetadata, + ).catch((refundError) => { + console.error('[v0] app-idea-chat refund failed:', refundError) + }) + throw error + } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) console.error('[v0] app-idea-chat error:', errorMsg) diff --git a/app/api/build-app/route.ts b/app/api/build-app/route.ts index 45c0852..9eca9d9 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,25 @@ 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() + const addFile = (path: string, purpose: string) => { + const cleanPath = path.trim() + if (!cleanPath || files.has(cleanPath)) return + files.set(cleanPath, { path: cleanPath, 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 files + return Array.from(files.values()) } async function createGitHubRepo( @@ -88,7 +94,7 @@ async function createGitHubRepo( body: JSON.stringify({ name: repoName, description, - private: false, + private: true, auto_init: false, }), }) @@ -110,8 +116,9 @@ async function pushFileToGitHub( content: string, ): Promise { const encoded = Buffer.from(content).toString('base64') + const encodedPath = path.split('/').map(encodeURIComponent).join('/') const res = await fetch( - `https://api.github.com/repos/${username}/${repoName}/contents/${path}`, + `https://api.github.com/repos/${username}/${repoName}/contents/${encodedPath}`, { method: 'PUT', headers: { @@ -128,7 +135,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 ?? res.statusText}`) } } @@ -190,7 +197,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 ?? res.statusText}`) } } @@ -201,6 +208,14 @@ export async function POST(request: NextRequest) { const send = (data: object) => { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) } + let chargedUserId: string | null = null + const refundBuildCredits = async (reason: string, metadata: Record) => { + if (!chargedUserId) return + await refundCredits(chargedUserId, CREDITS.BUILD_APP_COST, reason, metadata).catch((refundError) => { + console.error('[build-app] Failed to refund credits:', refundError) + }) + chargedUserId = null + } try { const user = await getCurrentUser() @@ -230,6 +245,14 @@ export async function POST(request: NextRequest) { } const cleanRepoName = repoName.trim().replace(/\s+/g, '-').toLowerCase() + const creditMetadata = { repoName: cleanRepoName, platform, blueprintName: blueprint.name } + const creditResult = await deductCredits(user.id, CREDITS.BUILD_APP_COST, 'build_app', creditMetadata) + if (!creditResult.success) { + send({ step: 'error', message: creditResult.error || 'Insufficient credits for Build This App.' }) + controller.close() + return + } + chargedUserId = user.id // Step 1 — determine files to generate const filesToGenerate = getFilesToGenerate(blueprint) @@ -264,6 +287,7 @@ export async function POST(request: NextRequest) { gitlabBranch = project.default_branch || 'main' } } catch (e) { + await refundBuildCredits('build app repository creation failed', creditMetadata) 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'}.`, @@ -283,9 +307,11 @@ export async function POST(request: NextRequest) { let content: string try { content = await generateSingleFile(blueprint, path, purpose, user.id) + if (!content.trim()) { + throw new Error(`Generated empty content for ${path}`) + } } catch (e) { - console.warn(`[build-app] Failed to generate ${path}:`, e) - content = `# Error generating ${path}\n# ${e instanceof Error ? e.message : String(e)}\n` + throw new Error(`Failed to generate ${path}: ${e instanceof Error ? e.message : String(e)}`) } // Push to platform @@ -311,12 +337,17 @@ export async function POST(request: NextRequest) { message: `${pushed} files generated and pushed successfully.`, repoUrl, filesCreated: pushed, + creditsUsed: CREDITS.BUILD_APP_COST, + creditsRemaining: creditResult.transaction?.balance_after, }) } catch (e) { console.error('[build-app] unhandled error:', e) + await refundBuildCredits('build app failed', { + error: e instanceof Error ? e.message : String(e), + }) controller.enqueue( encoder.encode( - `data: ${JSON.stringify({ step: 'error', message: 'An unexpected error occurred.' })}\n\n`, + `data: ${JSON.stringify({ step: 'error', message: e instanceof Error ? e.message : 'An unexpected error occurred.' })}\n\n`, ), ) } finally { diff --git a/app/api/code-completion/route.ts b/app/api/code-completion/route.ts index f7ede13..35c1077 100644 --- a/app/api/code-completion/route.ts +++ b/app/api/code-completion/route.ts @@ -5,6 +5,7 @@ import { batchGenerateCompletions, } from '@/lib/code-completion' import { getDb } from '@/lib/db' +import { getCurrentUser } from '@/lib/auth' const LANG_EXTENSIONS: Record = { python: ['.py'], @@ -24,18 +25,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 +66,11 @@ async function fetchSnippetsFromDb(language: string): Promise { export async function POST(request: NextRequest) { try { + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + const body = await request.json() const { incompleteCode, codebaseSnippets = [], language = 'python' } = body @@ -75,7 +83,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 +128,11 @@ export async function POST(request: NextRequest) { */ export async function PUT(request: NextRequest) { try { + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + const body = await request.json() const { codeSnippets = [], codebaseSnippets = [], language = 'python' } = body @@ -130,9 +143,16 @@ export async function PUT(request: NextRequest) { ) } + if (codeSnippets.length > 10) { + return NextResponse.json( + { error: 'Batch size is limited to 10 snippets' }, + { 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..c0308ba 100644 --- a/app/api/generate-scaffold/route.ts +++ b/app/api/generate-scaffold/route.ts @@ -1,6 +1,6 @@ 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' @@ -11,7 +11,7 @@ export async function POST(request: NextRequest) { 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,29 +32,28 @@ 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 creditMetadata = { appName, technologies } + const deductResult = await deductCredits(user.id, CREDITS.SCAFFOLD_COST, 'scaffold', creditMetadata) + 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 }, + ) } - const raw = await generateWithGateway({ - feature: 'scaffold', - userId: user.id, - maxOutputTokens: 8192, - messages: [ - { - role: 'user', - content: `Generate a complete project scaffold for "${appName}". + try { + const raw = await generateWithGateway({ + feature: 'scaffold', + userId: user.id, + maxOutputTokens: 8192, + messages: [ + { + role: 'user', + content: `Generate a complete project scaffold for "${appName}". Description: ${description} Technologies: ${(technologies ?? []).join(', ')} @@ -88,56 +87,51 @@ Example structure: "src/index.ts": "import express from 'express'\\nconst app = express()\\napp.get('/', (req, res) => res.json({ status: 'ok' }))\\napp.listen(3000)" } }`, - }, - ], - }) + }, + ], + }) - let scaffold - try { - let jsonStr = raw.trim() + let scaffold + try { + let jsonStr = raw.trim() - if (jsonStr.includes('```')) { - const match = jsonStr.match(/```(?:json)?\s*([\s\S]*?)\s*```/) - if (match?.[1]) { - jsonStr = match[1].trim() + if (jsonStr.includes('```')) { + const match = jsonStr.match(/```(?:json)?\s*([\s\S]*?)\s*```/) + if (match?.[1]) { + jsonStr = match[1].trim() + } } - } - if (!jsonStr.startsWith('{')) { - const objMatch = jsonStr.match(/\{[\s\S]*\}/) - if (objMatch) { - jsonStr = objMatch[0] + if (!jsonStr.startsWith('{')) { + const objMatch = jsonStr.match(/\{[\s\S]*\}/) + if (objMatch) { + jsonStr = objMatch[0] + } } - } - scaffold = JSON.parse(jsonStr) + scaffold = JSON.parse(jsonStr) - if (!scaffold.structure && !scaffold.files) { - throw new Error('Invalid scaffold structure - missing required fields') + if (!scaffold.structure && !scaffold.files) { + throw new Error('Invalid scaffold structure - missing required fields') + } + } catch (e) { + console.error('[scaffold] Failed to parse AI response:', raw.slice(0, 500)) + throw new Error(`Failed to parse scaffold: ${e instanceof Error ? e.message : 'Invalid JSON'}`) } - } catch (e) { - console.error('[scaffold] Failed to parse AI response:', raw.slice(0, 500)) - 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', { + return NextResponse.json({ + success: true, + scaffold, appName, - technologies, + creditsUsed: CREDITS.SCAFFOLD_COST, + creditsRemaining: deductResult.transaction?.balance_after, }) - - if (deductResult.success) { - creditsUsed = CREDITS.SCAFFOLD_COST - } + } catch (error) { + await refundCredits(user.id, CREDITS.SCAFFOLD_COST, 'scaffold generation failed', creditMetadata).catch((refundError) => { + console.error('[scaffold] Failed to refund credits:', refundError) + }) + throw error } - - return NextResponse.json({ - success: true, - scaffold, - appName, - creditsUsed, - }) } catch (error) { console.error('[scaffold] Generation error:', error) return NextResponse.json( diff --git a/app/api/pattern-analyzer/route.ts b/app/api/pattern-analyzer/route.ts index 10447f4..e0158b0 100644 --- a/app/api/pattern-analyzer/route.ts +++ b/app/api/pattern-analyzer/route.ts @@ -8,7 +8,7 @@ import { } from '@/lib/queries' import { getAnthropicModel } from '@/lib/anthropic-model' import { getCurrentUser } from '@/lib/auth' -import { deductCredits, CREDITS } from '@/lib/credits' +import { deductCredits, refundCredits, CREDITS } from '@/lib/credits' let __anthropicClient: Anthropic | null = null function getAnthropic(): Anthropic { @@ -61,11 +61,6 @@ export async function POST(request: NextRequest) { ) } - const creditResult = await deductCredits(user.id, CREDITS.PATTERN_ANALYZER_COST, 'pattern_analyzer', { analysisId }) - if (!creditResult.success) { - return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 }) - } - const analysis = await getAnalysisById(analysisId, user.id) if (!analysis) { return NextResponse.json({ error: 'Analysis not found' }, { status: 404 }) @@ -170,32 +165,50 @@ Respond ONLY with a valid JSON object (no markdown fences) matching this exact s ] }` - const response = await getAnthropic().messages.create({ - model: getAnthropicModel(), - max_tokens: 4096, - messages: [{ role: 'user', content: prompt }], - }) - - const raw = response.content[0].type === 'text' ? response.content[0].text.trim() : '' - - // Strip accidental markdown fences - const jsonText = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim() + const creditMetadata = { analysisId } + const creditResult = await deductCredits(user.id, CREDITS.PATTERN_ANALYZER_COST, 'pattern_analyzer', creditMetadata) + if (!creditResult.success) { + return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 }) + } - let parsed: { patterns: string[]; suggestions: ProjectSuggestion[] } try { - parsed = JSON.parse(jsonText) - } catch { - return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 }) - } + const response = await getAnthropic().messages.create({ + model: getAnthropicModel(), + max_tokens: 4096, + messages: [{ role: 'user', content: prompt }], + }) + + const raw = response.content[0].type === 'text' ? response.content[0].text.trim() : '' + + // Strip accidental markdown fences + const jsonText = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim() + + let parsed: { patterns: string[]; suggestions: ProjectSuggestion[] } + try { + parsed = JSON.parse(jsonText) + } catch { + throw new Error('Failed to parse AI response') + } - const result: PatternAnalyzerResult = { - patterns: parsed.patterns || [], - suggestions: parsed.suggestions || [], - topTechnologies, - analysisId, - } + const result: PatternAnalyzerResult = { + patterns: parsed.patterns || [], + suggestions: parsed.suggestions || [], + topTechnologies, + analysisId, + } - return NextResponse.json(result) + return NextResponse.json(result) + } catch (error) { + await refundCredits( + user.id, + CREDITS.PATTERN_ANALYZER_COST, + 'pattern analyzer failed', + creditMetadata, + ).catch((refundError) => { + console.error('[pattern-analyzer] refund failed:', refundError) + }) + throw error + } } catch (error) { console.error('[pattern-analyzer] error:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/app/api/projects/[id]/milestones/[milestoneId]/route.ts b/app/api/projects/[id]/milestones/[milestoneId]/route.ts index 0b04cb1..bfe4787 100644 --- a/app/api/projects/[id]/milestones/[milestoneId]/route.ts +++ b/app/api/projects/[id]/milestones/[milestoneId]/route.ts @@ -11,8 +11,9 @@ export async function PATCH( const { milestoneId } = await params try { + const { id } = await params 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 +29,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..7b2de1b 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -181,41 +181,36 @@ 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, updated.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 +225,22 @@ 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, updated.current_balance + FROM updated RETURNING * ` diff --git a/lib/queries.ts b/lib/queries.ts index 7dd365e..3d4e1c3 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -380,6 +380,70 @@ export async function deleteBlueprintsByAnalysis(analysisId: string): Promise { + if (blueprints.length === 0) return [] + + const sql = getDb() + const rows = JSON.stringify(blueprints) + const [, inserted] = await sql.transaction([ + sql` + DELETE FROM app_blueprints + WHERE analysis_id = ${analysisId} AND user_id = ${userId} + `, + sql` + INSERT INTO app_blueprints ( + analysis_id, user_id, name, description, app_type, complexity, reuse_percentage, + existing_files, missing_files, estimated_effort, technologies, ai_explanation + ) + SELECT + ${analysisId}, + ${userId}, + replacement.name, + replacement.description, + replacement.app_type, + replacement.complexity, + replacement.reuse_percentage, + replacement.existing_files, + replacement.missing_files, + replacement.estimated_effort, + replacement.technologies, + replacement.ai_explanation + FROM jsonb_to_recordset(${rows}::jsonb) AS replacement( + name text, + description text, + app_type text, + complexity text, + reuse_percentage integer, + existing_files jsonb, + missing_files jsonb, + estimated_effort text, + technologies jsonb, + ai_explanation text + ) + RETURNING * + `, + ]) + + return inserted as AppBlueprint[] +} + export async function updateUserBilling(userId: string, data: UserBillingUpdate): Promise { const sql = getDb() await sql` @@ -1044,27 +1108,55 @@ 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 SET completed = true, completed_at = NOW() WHERE id = ${id} + AND project_id = ${projectId} + AND EXISTS ( + SELECT 1 FROM projects + WHERE projects.id = project_milestones.project_id + AND projects.user_id = ${userId} + ) RETURNING * ` : await sql` UPDATE project_milestones SET completed = false, completed_at = NULL WHERE id = ${id} + AND project_id = ${projectId} + AND EXISTS ( + SELECT 1 FROM projects + WHERE projects.id = project_milestones.project_id + AND projects.user_id = ${userId} + ) RETURNING * ` 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 + WHERE id = ${id} + AND project_id = ${projectId} + AND EXISTS ( + SELECT 1 FROM projects + WHERE projects.id = project_milestones.project_id + AND projects.user_id = ${userId} + ) + RETURNING id + ` + return result.length > 0 } export async function seedDefaultMilestones(projectId: string): Promise { diff --git a/scripts/critical-regression-check.mjs b/scripts/critical-regression-check.mjs new file mode 100644 index 0000000..603f9b9 --- /dev/null +++ b/scripts/critical-regression-check.mjs @@ -0,0 +1,159 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const root = process.cwd() + +function read(path) { + return readFileSync(join(root, path), 'utf8') +} + +function assertIncludes(path, needle, message) { + const text = read(path) + if (!text.includes(needle)) { + throw new Error(`${message}\nExpected ${path} to include: ${needle}`) + } +} + +function assertExcludes(path, needle, message) { + const text = read(path) + if (text.includes(needle)) { + throw new Error(`${message}\nExpected ${path} not to include: ${needle}`) + } +} + +function assertMatches(path, pattern, message) { + const text = read(path) + if (!pattern.test(text)) { + throw new Error(`${message}\nExpected ${path} to match: ${pattern}`) + } +} + +assertMatches( + 'app/api/projects/[id]/milestones/[milestoneId]/route.ts', + /toggleMilestone\(milestoneId,\s*id,\s*user\.id,/, + 'Milestone PATCH must scope by route project and authenticated user.', +) +assertMatches( + 'app/api/projects/[id]/milestones/[milestoneId]/route.ts', + /deleteMilestone\(milestoneId,\s*id,\s*user\.id\)/, + 'Milestone DELETE must scope by route project and authenticated user.', +) +assertIncludes( + 'lib/queries.ts', + 'projects.user_id = ${userId}', + 'Milestone helper SQL must enforce project ownership.', +) + +assertIncludes( + 'app/api/code-completion/route.ts', + "import { getCurrentUser } from '@/lib/auth'", + 'Code completion must authenticate API callers.', +) +assertIncludes( + 'app/api/code-completion/route.ts', + 'JOIN repositories r ON r.id = rf.repository_id', + 'Code completion fallback snippets must join repositories for ownership scope.', +) +assertIncludes( + 'app/api/code-completion/route.ts', + 'WHERE r.user_id = ${userId}', + 'Code completion fallback snippets must be scoped to the authenticated user.', +) + +assertIncludes( + 'app/api/generate-scaffold/route.ts', + "deductCredits(user.id, CREDITS.SCAFFOLD_COST, 'scaffold'", + 'Scaffold generation must bill the authenticated user.', +) +assertIncludes( + 'app/api/generate-scaffold/route.ts', + "refundCredits(user.id, CREDITS.SCAFFOLD_COST, 'scaffold generation failed'", + 'Scaffold generation failures after billing must refund credits.', +) +assertExcludes( + 'app/api/generate-scaffold/route.ts', + 'deductCredits(userId', + 'Scaffold generation must not trust a client-supplied userId.', +) + +assertIncludes( + 'app/api/analyses/[id]/analyze/route.ts', + "import { getCurrentUser } from '@/lib/auth'", + 'Legacy analyze must authenticate callers.', +) +assertIncludes( + 'app/api/analyses/[id]/analyze/route.ts', + "deductCredits(user.id, CREDITS.ANALYSIS_COST, 'analysis'", + 'Legacy analyze must bill the authenticated user.', +) +assertExcludes( + 'app/api/analyses/[id]/analyze/route.ts', + 'userId: string', + 'Legacy analyze must not accept a client-controlled billing userId.', +) + +assertIncludes( + 'app/api/app-idea-chat/route.ts', + "refundCredits(\n user.id,\n CREDITS.PATTERN_ANALYZER_COST,\n 'app idea chat failed'", + 'App Idea Chat failures after billing must refund credits.', +) +assertIncludes( + 'app/api/pattern-analyzer/route.ts', + "refundCredits(\n user.id,\n CREDITS.PATTERN_ANALYZER_COST,\n 'pattern analyzer failed'", + 'Pattern Analyzer failures after billing must refund credits.', +) + +assertExcludes( + 'app/api/analyses/[id]/run/route.ts', + 'deleteBlueprintsByAnalysis(id)', + 'Analysis reruns must not delete old blueprints before replacements are ready.', +) +assertIncludes( + 'app/api/analyses/[id]/run/route.ts', + 'replaceBlueprintsForAnalysis(', + 'Analysis reruns must use atomic blueprint replacement.', +) +assertIncludes( + 'lib/queries.ts', + 'await sql.transaction([', + 'Blueprint replacement must run delete and insert in one database transaction.', +) + +assertIncludes( + 'app/api/build-app/route.ts', + 'private: true', + 'Build-app GitHub repositories must be private by default.', +) +assertIncludes( + 'app/api/build-app/route.ts', + "deductCredits(user.id, CREDITS.BUILD_APP_COST, 'build_app'", + 'Build-app must bill the authenticated user before AI work.', +) +assertIncludes( + 'app/api/build-app/route.ts', + "await refundBuildCredits('build app failed'", + 'Build-app failures after billing must refund credits.', +) +assertExcludes( + 'app/api/build-app/route.ts', + 'content = `# Error generating', + 'Build-app must not push error stub files after generation failures.', +) +assertMatches( + 'app/api/build-app/route.ts', + /if \(!res\.ok\) \{\s*const err = \(await res\.json\(\)\).*throw new Error\(`Failed to push/s, + 'Build-app push failures must throw instead of warning and continuing.', +) + +assertIncludes( + 'lib/credits.ts', + 'AND current_balance >= ${amount}', + 'Credit deductions must be atomic and conditional on sufficient balance.', +) +assertIncludes( + 'lib/credits.ts', + 'SET current_balance = current_balance + ${amount}', + 'Credit refunds must update balances atomically.', +) + +console.log('Critical regression checks passed') From 46a36699dc65b5e1c92767da68e25ce835721ad0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 11:13:35 +0000 Subject: [PATCH 2/2] Clean up analyze route whitespace Co-authored-by: Cole Collins --- app/api/analyses/[id]/analyze/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/analyses/[id]/analyze/route.ts b/app/api/analyses/[id]/analyze/route.ts index d140944..92de917 100644 --- a/app/api/analyses/[id]/analyze/route.ts +++ b/app/api/analyses/[id]/analyze/route.ts @@ -55,7 +55,7 @@ export async function POST(request: NextRequest) { try { // Get all repo files from database const filesByRepo: Record = {} - + for (const repo of selectedRepos) { // Fetch repo structure from GitHub API const files = await fetchRepoStructure(repo)