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
40 changes: 15 additions & 25 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 { deductCredits, CREDITS } from '@/lib/credits'
import { getCurrentUser } from '@/lib/auth'

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

Expand All @@ -21,28 +22,14 @@ interface AppSuggestion {

export async function POST(request: NextRequest) {
try {
const { analysisId, selectedRepos, userId } = (await request.json()) as {
analysisId: string
selectedRepos: SelectedRepository[]
userId: string
const user = await getCurrentUser()
if (!user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
}

// Check credit balance before proceeding
if (!userId) {
return NextResponse.json({ error: 'User ID required' }, { status: 401 })
}

const currentBalance = await getCreditBalance(userId)
if (currentBalance < CREDITS.ANALYSIS_COST) {
return NextResponse.json(
{
error: 'Insufficient credits',
required: CREDITS.ANALYSIS_COST,
available: currentBalance,
message: 'Upgrade to Pro to get unlimited analyses with 3,000 monthly credits.',
},
{ status: 402 }
)
const { analysisId, selectedRepos } = (await request.json()) as {
analysisId: string
selectedRepos: SelectedRepository[]
}

// Get all repo files from database
Expand Down Expand Up @@ -95,16 +82,19 @@ Return as JSON array of app suggestions. Focus on practical, buildable applicati
}

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

if (!deductResult.success) {
console.error('Failed to deduct credits:', deductResult.error)
return NextResponse.json(
{ error: 'Failed to process analysis' },
{ status: 500 }
{
error: deductResult.error ?? 'Insufficient credits',
required: CREDITS.ANALYSIS_COST,
message: 'Upgrade to Pro to get unlimited analyses with 3,000 monthly credits.',
},
{ status: 402 }
)
}

Expand Down
105 changes: 70 additions & 35 deletions app/api/build-app/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest } from 'next/server'
import { generateWithGateway } from '@/lib/ai-gateway'
import { aiConfigErrorMessage, generateWithGateway, isAiConfigured } from '@/lib/ai-gateway'
import { getCurrentUser } from '@/lib/auth'
import { CREDITS, deductCredits, refundCredits } from '@/lib/credits'
import { getSubscriptionByGithubId, upsertSubscription, type AppBlueprint } from '@/lib/queries'
import { hasProAccess } from '@/lib/pro-access'

Expand Down Expand Up @@ -56,20 +57,29 @@ Just the file content itself, ready to save.`

/** Build the list of all files to generate */
function getFilesToGenerate(blueprint: BuildAppRequest['blueprint']): Array<{ path: string; purpose: string }> {
const files: Array<{ path: string; purpose: string }> = []
const filesByPath = new Map<string, { path: string; purpose: string }>()
const addFile = (path: string, purpose: string) => {
if (!filesByPath.has(path)) {
filesByPath.set(path, { path, purpose })
}
}

// Missing files from the blueprint
for (const f of blueprint.missing_files) {
files.push({ path: f.name, purpose: f.purpose })
addFile(f.name, f.purpose)
}

// Standard project files
files.push({ path: 'README.md', purpose: 'Comprehensive setup, usage, and API documentation' })
files.push({ path: 'package.json', purpose: 'Project dependencies and scripts for the tech stack' })
files.push({ path: '.env.example', purpose: 'All required environment variables with placeholder values' })
files.push({ path: '.gitignore', purpose: 'Gitignore file appropriate for this stack' })
addFile('README.md', 'Comprehensive setup, usage, and API documentation')
addFile('package.json', 'Project dependencies and scripts for the tech stack')
addFile('.env.example', 'All required environment variables with placeholder values')
addFile('.gitignore', 'Gitignore file appropriate for this stack')

return Array.from(filesByPath.values())
}

return files
function gitHubContentsPath(path: string): string {
return path.split('/').map(encodeURIComponent).join('/')
}

async function createGitHubRepo(
Expand All @@ -88,7 +98,7 @@ async function createGitHubRepo(
body: JSON.stringify({
name: repoName,
description,
private: false,
private: true,
auto_init: false,
}),
})
Expand All @@ -111,7 +121,7 @@ async function pushFileToGitHub(
): Promise<void> {
const encoded = Buffer.from(content).toString('base64')
const res = await fetch(
`https://api.github.com/repos/${username}/${repoName}/contents/${path}`,
`https://api.github.com/repos/${username}/${repoName}/contents/${gitHubContentsPath(path)}`,
{
method: 'PUT',
headers: {
Expand All @@ -128,7 +138,7 @@ async function pushFileToGitHub(

if (!res.ok) {
const err = (await res.json()) as { message?: string }
console.warn(`[build-app] Failed to push ${path}: ${err.message}`)
throw new Error(err.message ?? `Failed to push ${path}`)
}
}

Expand Down Expand Up @@ -190,23 +200,37 @@ async function pushFileToGitLab(

if (!res.ok) {
const err = (await res.json()) as { message?: string }
console.warn(`[build-app] Failed to push ${path} to GitLab: ${err.message}`)
throw new Error(err.message ?? `Failed to push ${path} to GitLab`)
}
}

export async function POST(request: NextRequest) {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
let closed = false
const send = (data: object) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
}
const close = () => {
if (!closed) {
closed = true
controller.close()
}
}
let charged = false
let chargedUserId = ''
let refundMetadata: Record<string, unknown> = {}

try {
if (!isAiConfigured()) {
send({ step: 'error', message: aiConfigErrorMessage() })
return
}

const user = await getCurrentUser()
if (!user) {
send({ step: 'error', message: 'Sign in before building an app.' })
controller.close()
return
}

Expand All @@ -216,20 +240,30 @@ export async function POST(request: NextRequest) {
}
if (!hasProAccess(user, sub)) {
send({ step: 'error', message: 'Build This App is available on paid plans. Upgrade to create and push a generated repo.' })
controller.close()
return
}

const body = (await request.json()) as BuildAppRequest
const { platform, repoName, blueprint } = body

if (!repoName?.trim()) {
send({ step: 'error', message: 'Repository name is required.' })
controller.close()
if (!repoName?.trim() || !blueprint?.name || !Array.isArray(blueprint.missing_files) || !Array.isArray(blueprint.existing_files) || !Array.isArray(blueprint.technologies)) {
send({ step: 'error', message: 'Repository name and blueprint details are required.' })
return
}

const cleanRepoName = repoName.trim().replace(/\s+/g, '-').toLowerCase()
const creditResult = await deductCredits(user.id, CREDITS.BUILD_APP_COST, 'build_app', {
repoName: cleanRepoName,
platform,
blueprintName: blueprint.name,
})
if (!creditResult.success) {
send({ step: 'error', message: creditResult.error ?? 'Insufficient credits.' })
return
}
charged = true
chargedUserId = user.id
refundMetadata = { repoName: cleanRepoName, platform, blueprintName: blueprint.name }

// Step 1 — determine files to generate
const filesToGenerate = getFilesToGenerate(blueprint)
Expand Down Expand Up @@ -264,12 +298,7 @@ export async function POST(request: NextRequest) {
gitlabBranch = project.default_branch || 'main'
}
} catch (e) {
send({
step: 'error',
message: `Could not create repository: ${e instanceof Error ? e.message : String(e)}. Make sure you are connected to ${platform === 'github' ? 'GitHub' : 'GitLab'}.`,
})
controller.close()
return
throw new Error(`Could not create repository: ${e instanceof Error ? e.message : String(e)}. Make sure you are connected to ${platform === 'github' ? 'GitHub' : 'GitLab'}.`)
}

send({ step: 'repo_created', message: 'Repository created. Generating and pushing files…', repoUrl })
Expand All @@ -280,12 +309,11 @@ export async function POST(request: NextRequest) {

for (const { path, purpose } of filesToGenerate) {
// Generate this file
let content: string
try {
content = await generateSingleFile(blueprint, path, purpose, user.id)
} catch (e) {
console.warn(`[build-app] Failed to generate ${path}:`, e)
content = `# Error generating ${path}\n# ${e instanceof Error ? e.message : String(e)}\n`
const content = await generateSingleFile(blueprint, path, purpose, user.id).catch((e) => {
throw new Error(`Failed to generate ${path}: ${e instanceof Error ? e.message : String(e)}`)
})
if (!content.trim()) {
throw new Error(`Failed to generate ${path}: AI returned empty content`)
}

// Push to platform
Expand All @@ -311,16 +339,23 @@ export async function POST(request: NextRequest) {
message: `${pushed} files generated and pushed successfully.`,
repoUrl,
filesCreated: pushed,
creditsUsed: CREDITS.BUILD_APP_COST,
})
} catch (e) {
console.error('[build-app] unhandled error:', e)
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ step: 'error', message: 'An unexpected error occurred.' })}\n\n`,
),
)
if (charged) {
await refundCredits(
chargedUserId,
CREDITS.BUILD_APP_COST,
'Build This App failed before completion',
{ ...refundMetadata, error: e instanceof Error ? e.message : String(e) },
).catch((refundError) => {
console.error('[build-app] failed to refund credits:', refundError)
})
}
send({ step: 'error', message: e instanceof Error ? e.message : 'Build failed.' })
} finally {
controller.close()
close()
}
},
})
Expand Down
60 changes: 32 additions & 28 deletions app/api/generate-scaffold/route.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { aiConfigErrorMessage, generateWithGateway, isAiConfigured } from '@/lib/ai-gateway'
import { getCreditBalance, deductCredits, CREDITS } from '@/lib/credits'
import { deductCredits, refundCredits, CREDITS } from '@/lib/credits'
import { getCurrentUser } from '@/lib/auth'
import { getSubscriptionByGithubId, upsertSubscription } from '@/lib/queries'
import { hasProAccess } from '@/lib/pro-access'

export async function POST(request: NextRequest) {
let chargedUserId = ''
let charged = false
let chargeMetadata: Record<string, unknown> = {}

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 })
Expand All @@ -32,20 +36,23 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}

if (userId) {
const currentBalance = await getCreditBalance(userId)
if (currentBalance < CREDITS.SCAFFOLD_COST) {
return NextResponse.json(
{
error: 'Insufficient credits',
required: CREDITS.SCAFFOLD_COST,
available: currentBalance,
message: 'Upgrade to Pro to get unlimited scaffold generation with 3,000 monthly credits.',
},
{ status: 402 },
)
}
const deductResult = await deductCredits(user.id, CREDITS.SCAFFOLD_COST, 'scaffold', {
appName,
technologies,
})
if (!deductResult.success) {
return NextResponse.json(
{
error: deductResult.error ?? 'Insufficient credits',
required: CREDITS.SCAFFOLD_COST,
message: 'Upgrade to Pro to get unlimited scaffold generation with 3,000 monthly credits.',
},
{ status: 402 },
)
}
charged = true
chargedUserId = user.id
chargeMetadata = { appName, technologies }

const raw = await generateWithGateway({
feature: 'scaffold',
Expand Down Expand Up @@ -119,26 +126,23 @@ Example structure:
throw new Error(`Failed to parse scaffold: ${e instanceof Error ? e.message : 'Invalid JSON'}`)
}

let creditsUsed = 0
if (userId) {
const deductResult = await deductCredits(userId, CREDITS.SCAFFOLD_COST, 'scaffold', {
appName,
technologies,
})

if (deductResult.success) {
creditsUsed = CREDITS.SCAFFOLD_COST
}
}

return NextResponse.json({
success: true,
scaffold,
appName,
creditsUsed,
creditsUsed: CREDITS.SCAFFOLD_COST,
creditsRemaining: deductResult.transaction?.balance_after ?? 0,
})
} catch (error) {
console.error('[scaffold] Generation error:', error)
if (charged) {
await refundCredits(chargedUserId, CREDITS.SCAFFOLD_COST, 'Scaffold generation failed', {
...chargeMetadata,
error: error instanceof Error ? error.message : String(error),
}).catch((refundError) => {
console.error('[scaffold] Failed to refund credits:', refundError)
})
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to generate scaffold' },
{ status: 500 },
Expand Down
Loading
Loading