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
61 changes: 38 additions & 23 deletions app/api/analyses/[id]/analyze/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
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'
import { getAnalysisById } from '@/lib/queries'

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

Expand All @@ -20,30 +22,45 @@ interface AppSuggestion {
}

export async function POST(request: NextRequest) {
let chargedUserId: string | null = null
let creditMetadata: Record<string, unknown> = {}

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: 'Not authenticated' }, { status: 401 })
}

if (!analysisId || !Array.isArray(selectedRepos)) {
return NextResponse.json({ error: 'analysisId and selectedRepos are required' }, { status: 400 })
}

const analysis = await getAnalysisById(analysisId, user.id)
if (!analysis) {
return NextResponse.json({ error: 'Analysis not found' }, { status: 404 })
}

const currentBalance = await getCreditBalance(userId)
if (currentBalance < CREDITS.ANALYSIS_COST) {
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',
required: CREDITS.ANALYSIS_COST,
available: currentBalance,
message: 'Upgrade to Pro to get unlimited analyses with 3,000 monthly credits.',
message: deductResult.error ?? '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 @@ -92,20 +109,10 @@ Return as JSON array of app suggestions. Focus on practical, buildable applicati
}
} catch (e) {
console.error('Failed to parse AI response:', e)
throw new Error('Failed to parse AI response')
}

// 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 }
)
if (suggestions.length === 0) {
throw new Error('AI returned no app suggestions')
}

const newBalance = deductResult.transaction?.balance_after || 0
Expand All @@ -120,6 +127,14 @@ 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, 'Legacy analysis failed', {
...creditMetadata,
failure: error instanceof Error ? error.message : String(error),
}).catch((refundError) => {
console.error('Failed to refund analysis credits:', refundError)
})
}
return NextResponse.json({ error: 'Failed to analyze repositories' }, { status: 500 })
}
}
Expand Down
81 changes: 64 additions & 17 deletions app/api/analyses/[id]/run/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
updateAnalysisStatus,
createRepoFile,
createBlueprint,
deleteBlueprintsByAnalysis,
deleteBlueprintsByIds,
getBlueprintsByAnalysis,
getSubscriptionByGithubId,
upsertSubscription,
Expand All @@ -23,6 +23,7 @@ import { getAnthropicModel } from '@/lib/anthropic-model'
import { isOnFreeTier } from '@/lib/pro-access'
import { PLANS } from '@/lib/stripe'
import { generateGapsFromBlueprint, generateTemplatesFromBlueprints } from '@/lib/gap-generation'
import { CREDITS, deductCredits, refundCredits } from '@/lib/credits'

// Schema for AI-generated app blueprints
const complexityEnum = z.preprocess((val) => {
Expand Down Expand Up @@ -141,9 +142,24 @@ export async function POST(
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
let chargedUserId: string | null = null
let ownerUserId: string | null = null
let oldBlueprintsDeleted = false
const createdBlueprintIds: string[] = []
const send = (data: object) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
}
const refundAnalysisCharge = async (reason: string) => {
if (!chargedUserId) return
const userId = chargedUserId
chargedUserId = null
await refundCredits(userId, CREDITS.ANALYSIS_COST, 'Analysis run failed', {
analysisId: id,
failure: reason,
}).catch((refundError) => {
console.error('[analysis] Failed to refund credits:', refundError)
})
}

try {
const accessToken = await getCurrentAccessToken()
Expand All @@ -164,6 +180,7 @@ export async function POST(
controller.close()
return
}
ownerUserId = user.id

let sub = await getSubscriptionByGithubId(user.github_id).catch(() => null)
if (!sub) {
Expand Down Expand Up @@ -198,9 +215,21 @@ export async function POST(
return
}

const creditResult = await deductCredits(user.id, CREDITS.ANALYSIS_COST, 'analysis', { analysisId: id })
if (!creditResult.success) {
send({
status: 'failed',
error: creditResult.error ?? `Analysis requires ${CREDITS.ANALYSIS_COST} credits.`,
})
controller.close()
return
}
chargedUserId = user.id

const existingBlueprintIds = (await getBlueprintsByAnalysis(id, user.id)).map((bp) => bp.id)

// 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 @@ -263,6 +292,7 @@ export async function POST(
const msg = `No source files found to analyze. ${details} Add repos with application code or check your GitHub access token.`
send({ status: 'failed', error: msg })
await updateAnalysisStatus(id, 'failed', { error_message: msg })
await refundAnalysisCharge(msg)
controller.close()
return
}
Expand Down Expand Up @@ -420,6 +450,7 @@ 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 })
await refundAnalysisCharge(msg)
controller.close()
return
}
Expand All @@ -430,22 +461,31 @@ For each app blueprint:
.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,
})
try {
for (const bp of rankedBlueprints) {
const created = 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,
})
createdBlueprintIds.push(created.id)
}
} catch (error) {
await deleteBlueprintsByIds(createdBlueprintIds, user.id)
throw error
}

await deleteBlueprintsByIds(existingBlueprintIds, user.id)
oldBlueprintsDeleted = true
}

// Update to complete
Expand All @@ -462,12 +502,19 @@ For each app blueprint:
}
await generateTemplatesFromBlueprints(finalBlueprints)

chargedUserId = null
send({ status: 'complete', progress: 100, blueprints: finalBlueprints })
controller.close()

} catch (error) {
console.error('Analysis error:', error)
const errorDetail = error instanceof Error ? error.message : 'Unknown error'
if (ownerUserId && !oldBlueprintsDeleted) {
await deleteBlueprintsByIds(createdBlueprintIds, ownerUserId).catch((cleanupError) => {
console.error('Failed to clean up partial blueprints:', cleanupError)
})
}
await refundAnalysisCharge(errorDetail)
try {
await updateAnalysisStatus(id, 'failed', { error_message: errorDetail })
} catch (dbErr) {
Expand Down
19 changes: 16 additions & 3 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,6 +59,9 @@ function parseAppIdeaChatResponse(raw: string): AppIdeaChatResponse {
}

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

try {
if (!isAiConfigured()) {
return NextResponse.json({ error: aiConfigErrorMessage() }, { status: 503 })
Expand Down Expand Up @@ -96,6 +99,8 @@ export async function POST(request: NextRequest) {
if (!creditResult.success) {
return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 })
}
chargedUserId = user.id
chargedAnalysisId = analysisId

let codebaseContext = ''
if (analysisId) {
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,14 @@ 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: chargedAnalysisId,
failure: errorMsg,
}).catch((refundError) => {
console.error('[v0] Failed to refund app idea chat credits:', refundError)
})
}
return NextResponse.json({ error: errorMsg }, { status: 500 })
}
}
Loading
Loading