Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 38 additions & 21 deletions app/api/analyses/[id]/analyze/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { generateText } from 'ai'
import { getCreditBalance, deductCredits, CREDITS } from '@/lib/credits'
import { getCreditBalance, deductCredits, refundCredits, CREDITS } from '@/lib/credits'
import { getCurrentUser } from '@/lib/auth'

const model = 'openai/gpt-4-turbo'

Expand All @@ -20,19 +21,26 @@ interface AppSuggestion {
}

export async function POST(request: NextRequest) {
let chargedUserId: string | null = null
let analysisIdForRefund: string | null = null

try {
const { analysisId, selectedRepos, userId } = (await request.json()) as {
const user = await getCurrentUser()
if (!user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const { analysisId, selectedRepos } = (await request.json()) as {
analysisId: string
selectedRepos: SelectedRepository[]
userId: string
}
analysisIdForRefund = analysisId

// 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: 'selectedRepos is required' }, { status: 400 })
}

const currentBalance = await getCreditBalance(userId)
const currentBalance = await getCreditBalance(user.id)
if (currentBalance < CREDITS.ANALYSIS_COST) {
return NextResponse.json(
{
Expand All @@ -45,6 +53,24 @@ export async function POST(request: NextRequest) {
)
}

const deductResult = await deductCredits(user.id, CREDITS.ANALYSIS_COST, 'analysis', {
analysisId,
selectedRepos: selectedRepos.map((r) => r.name),
})

if (!deductResult.success) {
return NextResponse.json(
{
error: 'Insufficient credits',
required: CREDITS.ANALYSIS_COST,
available: await getCreditBalance(user.id),
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<string, RepositoryTreeFile[]> = {}

Expand Down Expand Up @@ -94,20 +120,6 @@ 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

return NextResponse.json({
Expand All @@ -120,6 +132,11 @@ Return as JSON array of app suggestions. Focus on practical, buildable applicati
})
} catch (error) {
console.error('Analysis error:', error)
if (chargedUserId) {
await refundCredits(chargedUserId, CREDITS.ANALYSIS_COST, 'Analysis failed', {
analysisId: analysisIdForRefund,
}).catch((refundError) => console.error('Failed to refund analysis credits:', refundError))
}
return NextResponse.json({ error: 'Failed to analyze repositories' }, { status: 500 })
}
}
Expand Down
60 changes: 46 additions & 14 deletions app/api/analyses/[id]/run/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import {
updateAnalysisStatus,
createRepoFile,
createBlueprint,
deleteBlueprintsByAnalysis,
deleteBlueprintsByAnalysisExcept,
deleteBlueprintsByIds,
getBlueprintsByAnalysis,
getSubscriptionByGithubId,
upsertSubscription,
incrementAnalysisUsage,
reserveAnalysisUsage,
releaseAnalysisUsage,
} from '@/lib/queries'
import { getAnthropicModel } from '@/lib/anthropic-model'
import { isOnFreeTier } from '@/lib/pro-access'
Expand Down Expand Up @@ -144,6 +146,7 @@ export async function POST(
const send = (data: object) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
}
let reservedUsageGithubId: number | null = null

try {
const accessToken = await getCurrentAccessToken()
Expand All @@ -159,7 +162,7 @@ export async function POST(
}

const user = await getCurrentUser()
if (!user) {
if (!user?.id) {
send({ error: 'Sign in with GitHub before running an analysis.' })
controller.close()
return
Expand Down Expand Up @@ -200,7 +203,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
Expand Down Expand Up @@ -269,6 +271,19 @@ export async function POST(

send({ status: 'scanning', progress: 40 })

if (isOnFreeTier(user, sub) && sub) {
const limit = PLANS.free.analyses_per_month
const reserved = await reserveAnalysisUsage(user.github_id, limit)
if (!reserved) {
const msg = `You've reached your free plan limit of ${limit} analyses per month. Upgrade to Pro for unlimited analyses.`
send({ error: msg, status: 'failed' })
await updateAnalysisStatus(id, 'failed', { error_message: msg })
controller.close()
return
}
reservedUsageGithubId = user.github_id
}

// Update to analyzing
await updateAnalysisStatus(id, 'analyzing', { total_files: allFiles.length })
send({ status: 'analyzing', progress: 50 })
Expand Down Expand Up @@ -420,18 +435,23 @@ For each app blueprint:
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 })
if (reservedUsageGithubId !== null) {
await releaseAnalysisUsage(reservedUsageGithubId)
reservedUsageGithubId = null
}
controller.close()
return
}

// Save blueprints to database
{
const rankedBlueprints = blueprintsFromAI
.map((bp) => normalizeBlueprint(bp))
.sort((a, b) => getOpportunityScore(b) - getOpportunityScore(a))
// Insert replacements before deleting old blueprints so failures never wipe prior results.
const rankedBlueprints = blueprintsFromAI
.map((bp) => normalizeBlueprint(bp))
.sort((a, b) => getOpportunityScore(b) - getOpportunityScore(a))
const createdBlueprintIds: string[] = []

try {
for (const bp of rankedBlueprints) {
await createBlueprint({
const created = await createBlueprint({
analysis_id: id,
user_id: user.id,
name: bp.name.slice(0, 255),
Expand All @@ -445,15 +465,19 @@ For each app blueprint:
technologies: bp.technologies,
ai_explanation: bp.explanation,
})
createdBlueprintIds.push(created.id)
}
} catch (insertError) {
await deleteBlueprintsByIds(id, user.id, createdBlueprintIds).catch((cleanupError) =>
console.error('[analysis] Failed to clean up partial replacement blueprints:', cleanupError),
)
throw insertError
}
await deleteBlueprintsByAnalysisExcept(id, user.id, createdBlueprintIds)

// 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)
)
reservedUsageGithubId = null

// Get final blueprints and hydrate recurring-value surfaces from them.
const finalBlueprints = await getBlueprintsByAnalysis(id, user.id)
Expand All @@ -468,6 +492,14 @@ For each app blueprint:
} catch (error) {
console.error('Analysis error:', error)
const errorDetail = error instanceof Error ? error.message : 'Unknown error'
try {
const githubId = (typeof reservedUsageGithubId === 'number') ? reservedUsageGithubId : null
if (githubId !== null) {
await releaseAnalysisUsage(githubId)
}
} catch (usageErr) {
console.error('Failed to release analysis usage after error:', usageErr)
}
try {
await updateAnalysisStatus(id, 'failed', { error_message: errorDetail })
} catch (dbErr) {
Expand Down
38 changes: 24 additions & 14 deletions app/api/app-idea-chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -59,13 +59,16 @@ function parseAppIdeaChatResponse(raw: string): AppIdeaChatResponse {
}

export async function POST(request: NextRequest) {
let chargedUserId: string | null = null
let analysisIdForRefund: string | undefined

try {
if (!isAiConfigured()) {
return NextResponse.json({ error: aiConfigErrorMessage() }, { status: 503 })
}

const user = await getCurrentUser()
if (!user) {
if (!user?.id) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
}

Expand All @@ -75,6 +78,7 @@ export async function POST(request: NextRequest) {
history?: ChatMessage[]
}
const analysisId = normalizeAnalysisId(rawAnalysisId)
analysisIdForRefund = analysisId

if (!message?.trim()) {
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
Expand All @@ -87,16 +91,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 })
}

let codebaseContext = ''
if (analysisId) {
try {
Expand Down Expand Up @@ -145,6 +139,17 @@ ${reusableFiles.length > 0 ? reusableFiles.map((file) => `- ${file}`).join('\n')
}
}

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 })
}
chargedUserId = user.id

const conversationHistory = normalizeConversationHistory(history).slice(-6)

const systemPrompt = `You are RepoFuse's VibeCoding app assembler. Help developers describe what they want to build, then turn their connected GitHub/GitLab repository knowledge into buildable app plans that reuse as much existing code, file structure, and patterns as possible.
Expand Down Expand Up @@ -198,11 +203,11 @@ Always respond with valid JSON only (no markdown fences):
try {
parsed = parseAppIdeaChatResponse(raw)
} catch {
return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 })
throw new Error('Failed to parse AI response')
}

if (!parsed.reply?.trim()) {
return NextResponse.json({ error: 'Empty AI response' }, { status: 500 })
throw new Error('Empty AI response')
}

return NextResponse.json({
Expand All @@ -216,6 +221,11 @@ Always respond with valid JSON only (no markdown fences):
if (error instanceof Error) {
console.error('[v0] Stack:', error.stack)
}
if (chargedUserId) {
await refundCredits(chargedUserId, CREDITS.PATTERN_ANALYZER_COST, 'App Idea Chat failed', {
analysisId: analysisIdForRefund,
}).catch((refundError) => console.error('[v0] Failed to refund app idea chat credits:', refundError))
}
return NextResponse.json({ error: errorMsg }, { status: 500 })
}
}
Loading
Loading