diff --git a/app/api/analyses/[id]/analyze/route.ts b/app/api/analyses/[id]/analyze/route.ts index 65e9bec..e45680f 100644 --- a/app/api/analyses/[id]/analyze/route.ts +++ b/app/api/analyses/[id]/analyze/route.ts @@ -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' @@ -19,43 +21,57 @@ interface AppSuggestion { is_complete?: boolean } -export async function POST(request: NextRequest) { +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { try { - const { analysisId, selectedRepos, userId } = (await request.json()) as { - analysisId: string + const { id } = await params + const user = await getCurrentUser() + if (!user?.id) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const { selectedRepos } = (await request.json()) as { 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 analysis = await getAnalysisById(id, user.id) + if (!analysis) { + return NextResponse.json({ error: 'Analysis not found' }, { status: 404 }) } - const currentBalance = await getCreditBalance(userId) - if (currentBalance < CREDITS.ANALYSIS_COST) { + const deductResult = await deductCredits(user.id, CREDITS.ANALYSIS_COST, 'analysis', { + analysisId: id, + selectedRepos: selectedRepos.map((r) => r.name), + }) + 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 } ) } - // 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 +92,42 @@ 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), - }) + const newBalance = deductResult.transaction?.balance_after || 0 - if (!deductResult.success) { - console.error('Failed to deduct credits:', deductResult.error) - return NextResponse.json( - { error: 'Failed to process analysis' }, - { status: 500 } - ) + return NextResponse.json({ + analysisId: id, + suggestions, + totalSuggestions: suggestions.length, + completeSuggestions: suggestions.filter((suggestion) => suggestion.is_complete).length, + creditsUsed: CREDITS.ANALYSIS_COST, + creditsRemaining: newBalance, + }) + } catch (e) { + await refundCredits(user.id, CREDITS.ANALYSIS_COST, 'Legacy analysis failed', { + analysisId: id, + }).catch((refundError) => { + console.error('Failed to refund analysis credits:', refundError) + }) + throw e } - - 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..834969d 100644 --- a/app/api/analyses/[id]/run/route.ts +++ b/app/api/analyses/[id]/run/route.ts @@ -14,6 +14,7 @@ import { createRepoFile, createBlueprint, deleteBlueprintsByAnalysis, + deleteBlueprintsByIds, getBlueprintsByAnalysis, getSubscriptionByGithubId, upsertSubscription, @@ -200,7 +201,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 @@ -430,21 +430,32 @@ 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, + const replacementBlueprintIds: string[] = [] + try { + for (const bp of rankedBlueprints) { + const blueprint = 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, + }) + replacementBlueprintIds.push(blueprint.id) + } + + await deleteBlueprintsByAnalysis(id, user.id, replacementBlueprintIds) + } catch (e) { + await deleteBlueprintsByIds(replacementBlueprintIds, user.id).catch((cleanupError) => { + console.error('[analysis] Failed to clean up partial replacement blueprints:', cleanupError) }) + throw e } } diff --git a/app/api/app-idea-chat/route.ts b/app/api/app-idea-chat/route.ts index 64b439e..3e389d7 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' @@ -186,30 +186,39 @@ 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', { + analysisId, + }).catch((refundError) => { + console.error('[v0] Failed to refund app idea chat credits:', 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..3d54479 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 { deductCredits, refundCredits, CREDITS } 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 }) + files.set(f.name, { path: f.name, purpose: 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' }) + files.set('README.md', { path: 'README.md', purpose: 'Comprehensive setup, usage, and API documentation' }) + files.set('package.json', { path: 'package.json', purpose: 'Project dependencies and scripts for the tech stack' }) + files.set('.env.example', { path: '.env.example', purpose: 'All required environment variables with placeholder values' }) + files.set('.gitignore', { path: '.gitignore', purpose: 'Gitignore file appropriate for this stack' }) - return files + return [...files.values()] } 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 ?? res.statusText}`) } } @@ -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 ?? res.statusText}`) } } @@ -202,11 +203,14 @@ export async function POST(request: NextRequest) { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) } + let chargedUserId: string | null = null + let buildCompleted = false + let buildMetadata: Record = {} + try { const user = await getCurrentUser() if (!user) { send({ step: 'error', message: 'Sign in before building an app.' }) - controller.close() return } @@ -216,21 +220,46 @@ 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 (platform !== 'github' && platform !== 'gitlab') { + send({ step: 'error', message: 'Choose GitHub or GitLab before building an app.' }) + return + } + if (!repoName?.trim()) { send({ step: 'error', message: 'Repository name is required.' }) - controller.close() return } const cleanRepoName = repoName.trim().replace(/\s+/g, '-').toLowerCase() + if ( + !blueprint?.name || + !Array.isArray(blueprint.existing_files) || + !Array.isArray(blueprint.missing_files) || + !Array.isArray(blueprint.technologies) + ) { + send({ step: 'error', message: 'Blueprint data is incomplete. Refresh the analysis and try again.' }) + return + } + + buildMetadata = { + platform, + repoName: cleanRepoName, + blueprintName: blueprint.name, + } + const creditResult = await deductCredits(user.id, CREDITS.BUILD_APP_COST, 'build_app', buildMetadata) + if (!creditResult.success) { + send({ step: 'error', message: creditResult.error || 'Insufficient credits for Build This App.' }) + return + } + chargedUserId = user.id + // Step 1 — determine files to generate const filesToGenerate = getFilesToGenerate(blueprint) send({ @@ -245,31 +274,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 }) @@ -279,14 +299,7 @@ export async function POST(request: NextRequest) { const total = filesToGenerate.length 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) // Push to platform if (platform === 'github') { @@ -306,6 +319,7 @@ export async function POST(request: NextRequest) { }) } + buildCompleted = true send({ step: 'done', message: `${pushed} files generated and pushed successfully.`, @@ -314,11 +328,16 @@ export async function POST(request: NextRequest) { }) } 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 This App failed', buildMetadata) + .catch((refundError) => { + console.error('[build-app] Failed to refund credits:', refundError) + }) + } + send({ + step: 'error', + message: e instanceof Error ? e.message : 'An unexpected error occurred.', + }) } finally { controller.close() } diff --git a/app/api/generate-scaffold/route.ts b/app/api/generate-scaffold/route.ts index 312c934..9c2d599 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,30 @@ 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: 'Insufficient credits', + required: CREDITS.SCAFFOLD_COST, + message: deductResult.error || 'Upgrade your plan to continue generating scaffolds.', + }, + { 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 +89,53 @@ 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, + creditsUsed: CREDITS.SCAFFOLD_COST, + }) + } catch (e) { + await refundCredits(user.id, CREDITS.SCAFFOLD_COST, 'Scaffold generation failed', { appName, technologies, + }).catch((refundError) => { + console.error('[scaffold] Failed to refund credits:', refundError) }) - - if (deductResult.success) { - creditsUsed = CREDITS.SCAFFOLD_COST - } + throw e } - - 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..2849bfd 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 }) @@ -77,57 +72,63 @@ export async function POST(request: NextRequest) { ) } - // Gather repo files and blueprints - 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() - - // Collect technology signals - const techCount: Record = {} - const purposesByCategory: Record = {} + 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 }) + } - for (const file of allFiles) { - for (const tech of file.technologies) { - techCount[tech] = (techCount[tech] || 0) + 1 - } - if (file.file_type && file.purpose) { - if (!purposesByCategory[file.file_type]) { - purposesByCategory[file.file_type] = [] + try { + // Gather repo files and blueprints + 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() + + // Collect technology signals + const techCount: Record = {} + const purposesByCategory: Record = {} + + for (const file of allFiles) { + for (const tech of file.technologies) { + techCount[tech] = (techCount[tech] || 0) + 1 + } + if (file.file_type && file.purpose) { + if (!purposesByCategory[file.file_type]) { + purposesByCategory[file.file_type] = [] + } + purposesByCategory[file.file_type].push(file.purpose) } - purposesByCategory[file.file_type].push(file.purpose) } - } - - const topTechnologies = Object.entries(techCount) - .sort((a, b) => b[1] - a[1]) - .slice(0, 15) - .map(([tech]) => tech) - - const filesSummary = allFiles - .slice(0, 120) - .map( - (f) => - `${f.path} [${f.file_type || 'unknown'}] score=${f.reusability_score} tech=[${f.technologies.join(', ')}] purpose="${f.purpose || ''}"`, - ) - .join('\n') - - const blueprintsSummary = - blueprints.length > 0 - ? blueprints - .slice(0, 10) - .map( - (b) => - `• ${b.name} (${b.app_type}, reuse ${b.reuse_percentage}%, ${b.complexity}) — ${b.description || ''}`, - ) - .join('\n') - : 'No blueprints generated yet.' - const prompt = `You are a senior product strategist and software architect. You have just scanned a developer's codebase and must suggest 5 original NEW project ideas — products or tools they could build — that are grounded in patterns you observe in their code. + const topTechnologies = Object.entries(techCount) + .sort((a, b) => b[1] - a[1]) + .slice(0, 15) + .map(([tech]) => tech) + + const filesSummary = allFiles + .slice(0, 120) + .map( + (f) => + `${f.path} [${f.file_type || 'unknown'}] score=${f.reusability_score} tech=[${f.technologies.join(', ')}] purpose="${f.purpose || ''}"`, + ) + .join('\n') + + const blueprintsSummary = + blueprints.length > 0 + ? blueprints + .slice(0, 10) + .map( + (b) => + `• ${b.name} (${b.app_type}, reuse ${b.reuse_percentage}%, ${b.complexity}) — ${b.description || ''}`, + ) + .join('\n') + : 'No blueprints generated yet.' + + const prompt = `You are a senior product strategist and software architect. You have just scanned a developer's codebase and must suggest 5 original NEW project ideas — products or tools they could build — that are grounded in patterns you observe in their code. ## Codebase summary @@ -170,32 +171,40 @@ 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 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() : '' + 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() + // 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 { - return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 }) - } + 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', { + analysisId, + }).catch((refundError) => { + console.error('[pattern-analyzer] Failed to refund credits:', 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..fb83960 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, Boolean(completed), id, user.id) 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..3bcb911 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -58,23 +58,14 @@ function toCount(value: string | number | null | undefined): number { // Initialize or get user credits export async function getOrCreateUserCredits(userId: string): Promise { const sql = getDb() - - // Try to get existing - const existing = await sql` - SELECT * FROM user_credits WHERE user_id = ${userId} - ` - - if (existing.length > 0) { - return existing[0] as UserCredit - } - - // Create new + const result = await sql` INSERT INTO user_credits (user_id, current_balance, total_granted, total_used) VALUES (${userId}, 0, 0, 0) + ON CONFLICT (user_id) DO UPDATE SET user_id = user_credits.user_id RETURNING * ` - + return result[0] as UserCredit } @@ -100,32 +91,26 @@ export async function grantCredits( 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}, - total_granted = total_granted + ${amount}, - last_renewal_date = CURRENT_TIMESTAMP - 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_granted = total_granted + ${amount}, + last_renewal_date = CURRENT_TIMESTAMP + WHERE user_id = ${userId} + RETURNING current_balance + ) INSERT INTO credit_transactions ( user_id, amount, transaction_type, reason, metadata, balance_after ) - VALUES ( - ${userId}, ${amount}, 'grant', ${reason}, ${JSON.stringify(metadata)}::jsonb, ${newBalance} - ) + SELECT ${userId}, ${amount}, 'grant', ${reason}, ${JSON.stringify(metadata)}::jsonb, current_balance + FROM updated RETURNING * ` - + return transaction[0] as CreditTransaction } @@ -137,40 +122,40 @@ export async function renewMonthlyCredits( metadata: CreditMetadata = {} ): Promise { const sql = getDb() - const userCredits = await getOrCreateUserCredits(userId) - const topUpAmount = Math.max(0, monthlyAllowance - userCredits.current_balance) - - if (topUpAmount === 0) { - await sql` - UPDATE user_credits - SET last_renewal_date = CURRENT_TIMESTAMP - WHERE user_id = ${userId} - ` - return null - } - - const newBalance = userCredits.current_balance + topUpAmount - - await sql` - UPDATE user_credits - SET - current_balance = ${newBalance}, - total_granted = total_granted + ${topUpAmount}, - last_renewal_date = CURRENT_TIMESTAMP - WHERE user_id = ${userId} - ` + await getOrCreateUserCredits(userId) const transaction = await sql` + WITH current_row AS ( + SELECT current_balance + FROM user_credits + WHERE user_id = ${userId} + FOR UPDATE + ), + updated AS ( + UPDATE user_credits uc + SET + current_balance = CASE + WHEN current_row.current_balance < ${monthlyAllowance} THEN ${monthlyAllowance} + ELSE uc.current_balance + END, + total_granted = total_granted + GREATEST(${monthlyAllowance} - current_row.current_balance, 0), + last_renewal_date = CURRENT_TIMESTAMP + FROM current_row + WHERE uc.user_id = ${userId} + RETURNING + uc.current_balance, + GREATEST(${monthlyAllowance} - current_row.current_balance, 0) AS top_up_amount + ) INSERT INTO credit_transactions ( user_id, amount, transaction_type, reason, metadata, balance_after ) - VALUES ( - ${userId}, ${topUpAmount}, 'renewal', ${reason}, ${JSON.stringify(metadata)}::jsonb, ${newBalance} - ) + SELECT ${userId}, top_up_amount, 'renewal', ${reason}, ${JSON.stringify(metadata)}::jsonb, current_balance + FROM updated + WHERE top_up_amount > 0 RETURNING * ` - return transaction[0] as CreditTransaction + return (transaction[0] as CreditTransaction | undefined) ?? null } // Deduct credits (for analysis, scaffold, build_app, or pattern_analyzer) @@ -181,41 +166,34 @@ 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,29 +208,23 @@ 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 * ` - + return transaction[0] as CreditTransaction } diff --git a/lib/queries.ts b/lib/queries.ts index 7dd365e..216b408 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -375,9 +375,51 @@ export async function getBlueprintsByAnalysis(analysisId: string, userId?: strin return blueprints as AppBlueprint[] } -export async function deleteBlueprintsByAnalysis(analysisId: string): Promise { +export async function deleteBlueprintsByAnalysis( + analysisId: string, + userId?: string, + exceptIds: string[] = [], +): Promise { const sql = getDb() - await sql`DELETE FROM app_blueprints WHERE analysis_id = ${analysisId}` + const keepIdsJson = JSON.stringify(exceptIds) + if (userId) { + await sql` + DELETE FROM app_blueprints + WHERE analysis_id = ${analysisId} + AND user_id = ${userId} + AND ( + ${exceptIds.length} = 0 + OR id::text NOT IN (SELECT jsonb_array_elements_text(${keepIdsJson}::jsonb)) + ) + ` + return + } + await sql` + DELETE FROM app_blueprints + WHERE analysis_id = ${analysisId} + AND ( + ${exceptIds.length} = 0 + OR id::text NOT IN (SELECT jsonb_array_elements_text(${keepIdsJson}::jsonb)) + ) + ` +} + +export async function deleteBlueprintsByIds(ids: string[], userId?: string): Promise { + if (ids.length === 0) return + const sql = getDb() + const idsJson = JSON.stringify(ids) + if (userId) { + await sql` + DELETE FROM app_blueprints + WHERE user_id = ${userId} + AND id::text IN (SELECT jsonb_array_elements_text(${idsJson}::jsonb)) + ` + return + } + await sql` + DELETE FROM app_blueprints + WHERE id::text IN (SELECT jsonb_array_elements_text(${idsJson}::jsonb)) + ` } export async function updateUserBilling(userId: string, data: UserBillingUpdate): Promise { @@ -1044,27 +1086,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, + completed: boolean, + projectId: string, + userId: string, +): Promise { const sql = getDb() const result = completed ? await sql` - UPDATE project_milestones + UPDATE project_milestones m SET completed = true, completed_at = NOW() - WHERE id = ${id} - RETURNING * + FROM projects p + WHERE m.id = ${id} + AND m.project_id = p.id + AND p.id = ${projectId} + AND p.user_id = ${userId} + RETURNING m.* ` : await sql` - UPDATE project_milestones + UPDATE project_milestones m SET completed = false, completed_at = NULL - WHERE id = ${id} - RETURNING * + FROM projects p + WHERE m.id = ${id} + AND m.project_id = p.id + AND p.id = ${projectId} + 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 m + USING projects p + WHERE m.id = ${id} + AND m.project_id = p.id + AND p.id = ${projectId} + AND p.user_id = ${userId} + RETURNING m.id + ` + return result.length > 0 } export async function seedDefaultMilestones(projectId: string): Promise {