diff --git a/app/api/analyses/[id]/analyze/route.ts b/app/api/analyses/[id]/analyze/route.ts deleted file mode 100644 index f48138d..0000000 --- a/app/api/analyses/[id]/analyze/route.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { generateText } from 'ai' -import { getCreditBalance, deductCredits, CREDITS } from '@/lib/credits' -import { getAnalysisById } from '@/lib/queries' - -const model = 'openai/gpt-4-turbo' - -interface SelectedRepository { - name: string - full_name: string - default_branch: string -} - -interface RepositoryTreeFile { - path: string - size?: number -} - -interface AppSuggestion { - is_complete?: boolean -} - -export async function POST(request: NextRequest) { - try { - const { analysisId, selectedRepos, userId } = (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 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 5,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 - } - - // 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]) => - `${name}: ${files.map(f => f.path).join(', ')}` -).join('\n')} - -Your task is to discover what applications could be built by combining files from these repositories. For each possible app, determine: -1. App Name -2. App Type (Web App, Mobile App, API, Library, etc.) -3. Description -4. Complete? (true/false - does it have 80%+ of needed files?) -5. Reuse Percentage (how much code can be reused) -6. Missing Files (if not complete) -7. Technologies Used -8. Difficulty Level (easy/medium/hard) -9. Fast Cash Label (if missing only 2-3 files) -10. Explanation - -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]) - } - } 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('[v0] 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) { - console.error('Analysis error:', error) - return NextResponse.json({ error: 'Failed to analyze repositories' }, { status: 500 }) - } -} - -async function fetchRepoStructure(repo: SelectedRepository): Promise { - try { - const response = await fetch( - `https://api.github.com/repos/${repo.full_name}/git/trees/${repo.default_branch}?recursive=1`, - { - headers: { - 'Accept': 'application/vnd.github+json', - }, - } - ) - - if (!response.ok) return [] - - const data = (await response.json()) as { - tree?: Array<{ path: string; size?: number; type: string }> - } - - return (data.tree ?? []) - .filter((item) => item.type === 'blob') - .slice(0, 100) // Limit to first 100 files - .map((item) => ({ - path: item.path, - size: item.size, - })) - } catch (error) { - console.error('Error fetching repo structure:', error) - return [] - } -} diff --git a/app/api/analyze/route.ts b/app/api/analyze/route.ts deleted file mode 100644 index a0f155c..0000000 --- a/app/api/analyze/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { scanCrossPlatformCode } from '@/lib/cross-platform-scanner' -import { discoverApps } from '@/lib/app-discovery' - -export async function POST(request: NextRequest) { - try { - console.log('[v0] Starting cross-platform analysis...') - - // Scan code from all connected platforms - const scannedFiles = await scanCrossPlatformCode() - console.log(`[v0] Scanned ${scannedFiles.length} files`) - - if (scannedFiles.length === 0) { - return NextResponse.json({ error: 'No code files found to analyze' }, { status: 400 }) - } - - // Use AI to discover buildable apps - const discoveredApps = await discoverApps(scannedFiles) - console.log(`[v0] Discovered ${discoveredApps.length} apps`) - - return NextResponse.json({ - success: true, - filesScanned: scannedFiles.length, - appsDiscovered: discoveredApps.length, - apps: discoveredApps, - files: scannedFiles, - }) - } catch (error) { - console.error('[v0] Analysis error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Analysis failed' }, - { status: 500 } - ) - } -} diff --git a/app/api/app-idea-chat/route.ts b/app/api/app-idea-chat/route.ts new file mode 100644 index 0000000..ff32a6c --- /dev/null +++ b/app/api/app-idea-chat/route.ts @@ -0,0 +1,218 @@ +import { NextRequest, NextResponse } from 'next/server' +import { Anthropic } from '@anthropic-ai/sdk' +import { + getAnalysisById, + getRepositoriesForAnalysis, + getBlueprintsByAnalysis, + getFilesByRepository, +} from '@/lib/queries' +import { getAnthropicModel } from '@/lib/anthropic-model' +import { getCurrentUser } from '@/lib/auth' +import { deductCredits, CREDITS } from '@/lib/credits' + +const anthropic = new Anthropic() + +export interface AppIdeaSuggestion { + name: string + tagline: string + description: string + type: string + difficulty: 'easy' | 'medium' | 'hard' + estimatedEffort: string + suggestedStack: string[] + monetizationAngle: string + whyNow: string +} + +export interface StarterFile { + path: string + purpose: string + technologies: string[] + repoName: string + relevance: string +} + +export interface AppIdeaChatResponse { + reply: string + suggestions: AppIdeaSuggestion[] + followUpQuestions: string[] + starterFiles?: StarterFile[] + templateSummary?: string +} + +export interface ChatMessage { + role: 'user' | 'assistant' + content: string +} + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const { message, analysisId, history = [] } = (await request.json()) as { + message: string + analysisId?: string + history?: ChatMessage[] + } + + if (!message?.trim()) { + return NextResponse.json({ error: 'Message is required' }, { status: 400 }) + } + + 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 }) + } + + // Load codebase context and raw file list when an analysis is selected + let codebaseContext = '' + let repoFiles: Array<{ path: string; purpose: string; technologies: string[]; repoName: string }> = [] + + if (analysisId) { + try { + const analysis = await getAnalysisById(analysisId) + if (analysis && analysis.status === 'complete') { + const [repositories, blueprints] = await Promise.all([ + getRepositoriesForAnalysis(analysisId), + getBlueprintsByAnalysis(analysisId), + ]) + + const allFiles = ( + await Promise.all( + repositories.map(async (r) => { + const files = await getFilesByRepository(r.id) + return files.map((f) => ({ ...f, repoName: r.name })) + }), + ) + ).flat() + + repoFiles = allFiles.map((f) => ({ + path: f.path, + purpose: f.purpose ?? '', + technologies: f.technologies ?? [], + repoName: f.repoName, + })) + + 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, 12) + .map(([t]) => t) + + codebaseContext = ` +## Developer's existing codebase +Repositories: ${repositories.map((r) => r.name).join(', ')} +Top technologies: ${topTech.join(', ')} +Total files: ${allFiles.length} +Existing blueprints: ${blueprints.slice(0, 5).map((b) => b.name).join(', ') || 'none yet'} + +Files sample (first 60): +${allFiles + .slice(0, 60) + .map((f) => ` [${f.repoName}] ${f.path} — ${f.purpose ?? ''}`) + .join('\n')} +` + } + } catch { + // Codebase context optional — continue without it + } + } + + const hasCodebase = repoFiles.length > 0 + const conversationHistory = history.slice(-6).map((m) => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })) + + const systemPrompt = `You are an expert product strategist helping developers figure out what to build next. You give concise, actionable advice. + +${codebaseContext} + +When the user describes something they want to build: +${hasCodebase ? `- Identify up to 6 files from their EXISTING codebase (listed above) that are most relevant as starter code for this project. Pick files that could be reused or adapted directly.` : '- They have no connected codebase yet; suggest ideas and tech stacks only.'} +- Suggest 2-3 concrete project ideas aligned with their request +- Ask one follow-up question to refine suggestions + +Respond with valid JSON only (no markdown fences): +{ + "reply": "conversational response, max 80 words", + "suggestions": [ + { + "name": "Project Name", + "tagline": "One punchy sentence", + "description": "2-3 sentences", + "type": "SaaS | CLI | API | Dashboard | Mobile | etc", + "difficulty": "easy | medium | hard", + "estimatedEffort": "e.g. 1–2 weeks", + "suggestedStack": ["tech1", "tech2"], + "monetizationAngle": "How to monetize", + "whyNow": "Why this is timely" + } + ], + "followUpQuestions": ["Question 1?", "Question 2?"], + ${hasCodebase ? `"starterFiles": [ + { + "path": "exact/path/from/codebase", + "purpose": "what this file does", + "technologies": ["tech"], + "repoName": "repo-name", + "relevance": "one sentence: why this file helps for what they want to build" + } + ], + "templateSummary": "One sentence describing what these files collectively give them as a starting point"` : `"starterFiles": [], "templateSummary": null`} +}` + + const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [ + ...conversationHistory, + { role: 'user', content: message }, + ] + + const response = await anthropic.messages.create({ + model: getAnthropicModel(), + max_tokens: 2048, + system: systemPrompt, + messages, + }) + + const raw = response.content[0].type === 'text' ? response.content[0].text.trim() : '' + const jsonText = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim() + + let parsed: AppIdeaChatResponse + try { + parsed = JSON.parse(jsonText) + } catch { + return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 }) + } + + // Validate starterFiles against actual repo files to avoid hallucinations + if (parsed.starterFiles?.length && repoFiles.length > 0) { + const pathSet = new Set(repoFiles.map((f) => f.path)) + const validFiles = parsed.starterFiles.filter((sf) => pathSet.has(sf.path)) + // Enrich with actual data from DB + parsed.starterFiles = validFiles.map((sf) => { + const actual = repoFiles.find((f) => f.path === sf.path) + return actual + ? { ...sf, purpose: actual.purpose || sf.purpose, technologies: actual.technologies } + : sf + }) + } + + return NextResponse.json(parsed) + } catch (error) { + console.error('[app-idea-chat] error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/api/build-app/route.ts b/app/api/build-app/route.ts index 6757ef0..836a90b 100644 --- a/app/api/build-app/route.ts +++ b/app/api/build-app/route.ts @@ -2,8 +2,12 @@ import { NextRequest } from 'next/server' import Anthropic from '@anthropic-ai/sdk' import { getCurrentUser } from '@/lib/auth' import { getAnthropicModel } from '@/lib/anthropic-model' +import { getCreditBalance, deductCredits, CREDITS } from '@/lib/credits' import type { AppBlueprint } from '@/lib/queries' +export const maxDuration = 120 +export const dynamic = 'force-dynamic' + const anthropic = new Anthropic() type Platform = 'github' | 'gitlab' @@ -59,16 +63,26 @@ Rules: Return format: {"path/to/file.ts": "...full content...", "README.md": "..."} ` - const response = await anthropic.messages.create({ + // Use streaming so Vercel keeps the function alive during generation + // and we aren't blocked waiting for a single large response. + let raw = '' + const stream = anthropic.messages.stream({ model: getAnthropicModel(), max_tokens: 8192, messages: [{ role: 'user', content: prompt }], }) + for await (const chunk of stream) { + if ( + chunk.type === 'content_block_delta' && + chunk.delta.type === 'text_delta' + ) { + raw += chunk.delta.text + } + } - const raw = response.content[0].type === 'text' ? response.content[0].text.trim() : '' - const jsonText = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim() + raw = raw.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim() - const obj = JSON.parse(jsonText) as Record + const obj = JSON.parse(raw) as Record const files: Record = {} for (const [k, v] of Object.entries(obj)) { files[k] = typeof v === 'string' ? v : JSON.stringify(v, null, 2) @@ -225,6 +239,19 @@ export async function POST(request: NextRequest) { const cleanRepoName = repoName.trim().replace(/\s+/g, '-').toLowerCase() + // Verify the user can afford the build up front, but only charge them + // once the app is actually built (see the deduction after 'done' below). + // This prevents losing credits when generation, repo creation, or pushing fails. + const balance = await getCreditBalance(user.id) + if (balance < CREDITS.BUILD_APP_COST) { + send({ + step: 'error', + message: `Insufficient credits. Required: ${CREDITS.BUILD_APP_COST}, Available: ${balance}`, + }) + controller.close() + return + } + // Step 1 — generate files with Claude send({ step: 'generating', message: 'Generating file contents with Claude…' }) @@ -299,6 +326,20 @@ export async function POST(request: NextRequest) { }) } + // The build succeeded — charge the user now. Deducting here (rather than + // up front) guarantees failed builds never cost credits. A deduction + // failure must not turn a successful build into an error, so it's logged + // rather than surfaced. + try { + await deductCredits(user.id, CREDITS.BUILD_APP_COST, 'build_app', { + repoName: cleanRepoName, + platform, + filesCreated: pushed, + }) + } catch (e) { + console.error('[build-app] credit deduction failed after successful build:', e) + } + send({ step: 'done', message: `${pushed} files pushed successfully.`, diff --git a/app/api/debt-fix/route.ts b/app/api/debt-fix/route.ts new file mode 100644 index 0000000..47d1c88 --- /dev/null +++ b/app/api/debt-fix/route.ts @@ -0,0 +1,269 @@ +import { NextRequest } from 'next/server' +import Anthropic from '@anthropic-ai/sdk' +import { getCurrentUser } from '@/lib/auth' +import { getAnthropicModel } from '@/lib/anthropic-model' +import { deductCredits, CREDITS } from '@/lib/credits' +import type { DebtIssue } from '@/app/api/debt-scan/route' + +export const maxDuration = 60 +export const dynamic = 'force-dynamic' + +const anthropic = new Anthropic() + +interface DebtFixRequest { + issue: DebtIssue + analysisId: string + repoOwner: string + repoName: string +} + +async function getFileFromGitHub( + accessToken: string, + owner: string, + repo: string, + path: string, +): Promise<{ content: string; sha: string } | null> { + const res = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + }, + }, + ) + if (!res.ok) return null + const data = (await res.json()) as { content?: string; sha?: string } + if (!data.content || !data.sha) return null + return { + content: Buffer.from(data.content, 'base64').toString('utf-8'), + sha: data.sha, + } +} + +async function generateFix( + issue: DebtIssue, + currentContent: string, +): Promise { + const prompt = `You are a senior engineer fixing technical debt. Apply the following fix to this file. + +Issue: ${issue.title} +Category: ${issue.category} +Severity: ${issue.severity} +Description: ${issue.description} +Suggestion: ${issue.suggestion} +${issue.fix ? `\nExpected change:\nBefore: ${issue.fix.before}\nAfter: ${issue.fix.after}` : ''} + +Current file content: +\`\`\` +${currentContent.slice(0, 4000)} +\`\`\` + +Return ONLY the complete fixed file content, no explanations, no markdown fences.` + + const response = await anthropic.messages.create({ + model: getAnthropicModel(), + max_tokens: 4096, + messages: [{ role: 'user', content: prompt }], + }) + + const raw = response.content[0].type === 'text' ? response.content[0].text.trim() : '' + return raw.replace(/^```(?:\w+)?\s*/i, '').replace(/\s*```\s*$/, '').trim() +} + +async function createGitHubPR( + accessToken: string, + owner: string, + repo: string, + filePath: string, + fixedContent: string, + sha: string, + issue: DebtIssue, +): Promise { + const branchName = `debt-fix/${issue.id}-${Date.now()}` + const encoded = Buffer.from(fixedContent).toString('base64') + + // Get default branch SHA + const repoRes = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github+json' }, + }) + if (!repoRes.ok) throw new Error('Failed to fetch repo info') + const repoData = (await repoRes.json()) as { default_branch: string } + const defaultBranch = repoData.default_branch || 'main' + + const refRes = await fetch( + `https://api.github.com/repos/${owner}/${repo}/git/ref/heads/${defaultBranch}`, + { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github+json' } }, + ) + if (!refRes.ok) throw new Error('Failed to get ref') + const refData = (await refRes.json()) as { object: { sha: string } } + const baseSha = refData.object.sha + + // Create branch + const branchRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ref: `refs/heads/${branchName}`, sha: baseSha }), + }) + if (!branchRes.ok) throw new Error('Failed to create branch') + + // Update file on new branch + const updateRes = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/${encodeURIComponent(filePath)}`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: `fix: ${issue.title}`, + content: encoded, + sha, + branch: branchName, + }), + }, + ) + if (!updateRes.ok) { + const err = (await updateRes.json()) as { message?: string } + throw new Error(err.message ?? 'Failed to update file') + } + + // Create PR + const prRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: `[RepoFuse] fix: ${issue.title}`, + body: `## Technical Debt Fix\n\n**Category:** ${issue.category}\n**Severity:** ${issue.severity}\n**File:** \`${filePath}\`\n\n### Problem\n${issue.description}\n\n### Fix Applied\n${issue.suggestion}\n\n---\n*Generated by RepoFuse Debt Scanner*`, + head: branchName, + base: defaultBranch, + }), + }) + if (!prRes.ok) { + const err = (await prRes.json()) as { message?: string } + throw new Error(err.message ?? 'Failed to create PR') + } + const prData = (await prRes.json()) as { html_url: string } + return prData.html_url +} + +export async function POST(request: NextRequest) { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + async start(controller) { + const send = (data: object) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) + } + + try { + const user = await getCurrentUser() + if (!user) { + send({ step: 'error', message: 'Sign in to apply fixes.' }) + controller.close() + return + } + + const body = (await request.json()) as DebtFixRequest + const { issue, repoOwner, repoName } = body + + if (!issue || !repoOwner || !repoName) { + send({ step: 'error', message: 'Missing required fields.' }) + controller.close() + return + } + + // Deduct credits before any AI work + const creditResult = await deductCredits( + user.id, + CREDITS.DEBT_FIX_COST, + 'debt_fix', + { issueId: issue.id, repoOwner, repoName }, + ) + if (!creditResult.success) { + send({ step: 'error', message: creditResult.error ?? 'Insufficient credits' }) + controller.close() + return + } + + // Step 1 — fetch current file + send({ step: 'fetching', message: 'Fetching current file from GitHub…' }) + + const fileData = await getFileFromGitHub( + user.access_token, + repoOwner, + repoName, + issue.file, + ) + + if (!fileData) { + send({ step: 'error', message: `Could not fetch file: ${issue.file}. Make sure the repository is connected.` }) + controller.close() + return + } + + // Step 2 — generate fix with Claude + send({ step: 'generating', message: 'Generating fix with Claude…' }) + + let fixedContent: string + try { + fixedContent = await generateFix(issue, fileData.content) + } catch (e) { + send({ + step: 'error', + message: `Fix generation failed: ${e instanceof Error ? e.message : String(e)}`, + }) + controller.close() + return + } + + // Step 3 — create PR + send({ step: 'pr_creating', message: 'Creating pull request on GitHub…' }) + + let prUrl: string + try { + prUrl = await createGitHubPR( + user.access_token, + repoOwner, + repoName, + issue.file, + fixedContent, + fileData.sha, + issue, + ) + } catch (e) { + send({ + step: 'error', + message: `PR creation failed: ${e instanceof Error ? e.message : String(e)}`, + }) + controller.close() + return + } + + send({ step: 'done', message: 'Pull request created!', prUrl }) + } catch (e) { + console.error('[debt-fix] unhandled error:', e) + send({ step: 'error', message: 'An unexpected error occurred.' }) + } finally { + controller.close() + } + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) +} diff --git a/app/api/debt-scan/route.ts b/app/api/debt-scan/route.ts new file mode 100644 index 0000000..4568c26 --- /dev/null +++ b/app/api/debt-scan/route.ts @@ -0,0 +1,147 @@ +import { NextRequest, NextResponse } from 'next/server' +import { Anthropic } from '@anthropic-ai/sdk' +import { + getAnalysisById, + getRepositoriesForAnalysis, + getBlueprintsByAnalysis, + getFilesByRepository, +} from '@/lib/queries' +import { getAnthropicModel } from '@/lib/anthropic-model' +import { getCurrentUser } from '@/lib/auth' +import { deductCredits, CREDITS } from '@/lib/credits' + +export const dynamic = 'force-dynamic' + +const anthropic = new Anthropic() + +export interface DebtIssue { + id: string + category: 'duplicate' | 'unused' | 'security' | 'slow_query' | 'outdated_dep' | 'quick_win' + severity: 'critical' | 'high' | 'medium' | 'low' + title: string + description: string + file: string + suggestion: string + autoFixable: boolean + fix?: { before: string; after: string } +} + +export interface DebtScanResult { + issues: DebtIssue[] +} + +export async function POST(request: NextRequest) { + try { + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const { analysisId } = (await request.json()) as { analysisId: string } + + if (!analysisId) { + return NextResponse.json({ error: 'analysisId is required' }, { status: 400 }) + } + + const creditResult = await deductCredits( + user.id, + CREDITS.DEBT_SCAN_COST, + 'debt_scan', + { analysisId }, + ) + if (!creditResult.success) { + return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 }) + } + + const analysis = await getAnalysisById(analysisId) + if (!analysis || analysis.status !== 'complete') { + return NextResponse.json({ error: 'Analysis not found or not complete' }, { status: 404 }) + } + + const [repositories, blueprints] = await Promise.all([ + getRepositoriesForAnalysis(analysisId), + getBlueprintsByAnalysis(analysisId), + ]) + + 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, 15) + .map(([t]) => t) + + const fileList = allFiles + .slice(0, 150) + .map((f) => ` ${f.path}${f.purpose ? ` — ${f.purpose}` : ''}`) + .join('\n') + + const systemPrompt = `You are an expert code reviewer and technical debt analyst. Analyze this repository's file structure and metadata to identify technical debt and code quality issues. + +Repositories: ${repositories.map((r) => r.name).join(', ')} +Technologies: ${topTech.join(', ')} +Total files: ${allFiles.length} +Existing blueprints: ${blueprints.slice(0, 5).map((b) => b.name).join(', ') || 'none'} + +Files (path — purpose): +${fileList} +${allFiles.length > 150 ? `\n ... and ${allFiles.length - 150} more files` : ''} + +Return ONLY valid JSON (no markdown fences): +{ + "issues": [ + { + "id": "unique-kebab-slug", + "category": "duplicate|unused|security|slow_query|outdated_dep|quick_win", + "severity": "critical|high|medium|low", + "title": "Short descriptive title", + "description": "What the problem is and why it matters", + "file": "path/to/file.ts", + "suggestion": "Specific actionable recommendation", + "autoFixable": true, + "fix": { "before": "problematic code snippet", "after": "fixed code snippet" } + } + ] +} + +Rules: +- Limit to 20 most impactful issues +- Only include "fix" when autoFixable is true and you can provide a concrete before/after snippet +- For security: flag .env files committed, plaintext tokens/passwords, missing auth checks, SQL injection risks +- For duplicates: flag files with very similar names or purposes across repos that should be merged +- For unused: flag files that appear orphaned based on naming patterns (e.g. old/deprecated/backup files) +- For slow_query: flag files with database access patterns that suggest N+1 queries or missing indexes +- For outdated_dep: flag very old technology patterns or deprecated APIs visible from file names/purposes +- For quick_win: flag simple high-impact improvements (missing error handling, missing .gitignore entries, etc.) +- Infer issues from file names and purposes — you don't have actual file content, so focus on structural and naming patterns` + + const response = await anthropic.messages.create({ + model: getAnthropicModel(), + max_tokens: 3000, + system: systemPrompt, + messages: [{ role: 'user', content: 'Scan this codebase for technical debt and return the JSON result.' }], + }) + + const raw = response.content[0].type === 'text' ? response.content[0].text.trim() : '' + const jsonText = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim() + + let parsed: DebtScanResult + try { + parsed = JSON.parse(jsonText) + } catch { + return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 }) + } + + return NextResponse.json(parsed) + } catch (error) { + console.error('[debt-scan] error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/app/api/launch-preview/route.ts b/app/api/launch-preview/route.ts new file mode 100644 index 0000000..50b28ea --- /dev/null +++ b/app/api/launch-preview/route.ts @@ -0,0 +1,308 @@ +import { NextRequest } from 'next/server' +import Anthropic from '@anthropic-ai/sdk' +import { getCurrentUser } from '@/lib/auth' +import { getAnthropicModel } from '@/lib/anthropic-model' +import { deductCredits, CREDITS } from '@/lib/credits' + +export const maxDuration = 120 +export const dynamic = 'force-dynamic' + +const anthropic = new Anthropic() + +interface LaunchPreviewRequest { + repoOwner: string + repoName: string + vercelToken: string +} + +async function getGitHubFile(token: string, owner: string, repo: string, path: string) { + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${path}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + }, + }) + if (!res.ok) return null + const data = (await res.json()) as { content: string; sha: string } + const content = Buffer.from(data.content, 'base64').toString('utf-8') + return { content, sha: data.sha } +} + +async function fixDependencies(packageJson: string): Promise { + const response = await anthropic.messages.create({ + model: getAnthropicModel(), + max_tokens: 2000, + messages: [ + { + role: 'user', + content: `You are a Node.js expert. Update this package.json to use the latest stable versions of all dependencies. Keep the structure and all other fields identical. Only update version strings to current stable releases. Do NOT use "latest" — use semver ranges like "^18.3.1". Return ONLY valid JSON, no markdown fences. + +${packageJson}`, + }, + ], + }) + const raw = response.content[0].type === 'text' ? response.content[0].text.trim() : '' + if (!raw) return packageJson + return raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim() +} + +async function pushGitHubFile( + token: string, + owner: string, + repo: string, + path: string, + content: string, + sha: string, + message: string, +) { + const encoded = Buffer.from(content).toString('base64') + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${path}`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ message, content: encoded, sha }), + }) + if (!res.ok) { + const err = (await res.json()) as { message?: string } + throw new Error(err.message ?? 'Failed to push file') + } +} + +async function getGitHubRepoId(token: string, owner: string, repo: string): Promise { + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json' }, + }) + if (!res.ok) throw new Error('Could not fetch repository info from GitHub') + const data = (await res.json()) as { id: number; default_branch: string } + return data.id +} + +async function getGitHubRepoDefaultBranch(token: string, owner: string, repo: string): Promise { + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json' }, + }) + if (!res.ok) return 'main' + const data = (await res.json()) as { default_branch: string } + return data.default_branch || 'main' +} + +async function createVercelProject( + vercelToken: string, + name: string, + repoOwner: string, + repoName: string, +) { + const res = await fetch('https://api.vercel.com/v10/projects', { + method: 'POST', + headers: { + Authorization: `Bearer ${vercelToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name, + gitRepository: { repo: `${repoOwner}/${repoName}`, type: 'github' }, + }), + }) + if (!res.ok) { + const err = (await res.json()) as { error?: { message?: string } } + throw new Error(err.error?.message ?? 'Failed to create Vercel project') + } + return (await res.json()) as { id: string; name: string } +} + +async function createVercelDeployment( + vercelToken: string, + projectName: string, + repoId: number, + ref: string, +) { + const res = await fetch('https://api.vercel.com/v13/deployments', { + method: 'POST', + headers: { + Authorization: `Bearer ${vercelToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: projectName, + gitSource: { type: 'github', repoId, ref }, + }), + }) + if (!res.ok) { + const err = (await res.json()) as { error?: { message?: string } } + throw new Error(err.error?.message ?? 'Failed to trigger deployment') + } + return (await res.json()) as { id: string; url: string; readyState: string } +} + +async function pollDeployment(vercelToken: string, deploymentId: string): Promise { + const maxMs = 90_000 + const start = Date.now() + while (Date.now() - start < maxMs) { + await new Promise((r) => setTimeout(r, 6000)) + const res = await fetch(`https://api.vercel.com/v13/deployments/${deploymentId}`, { + headers: { Authorization: `Bearer ${vercelToken}` }, + }) + if (!res.ok) continue + const data = (await res.json()) as { readyState: string; url: string } + if (data.readyState === 'READY') return `https://${data.url}` + if (data.readyState === 'ERROR' || data.readyState === 'CANCELED') { + throw new Error(`Deployment ended with state: ${data.readyState}`) + } + } + throw new Error('Deployment timed out — it may still be building on Vercel') +} + +export async function POST(request: NextRequest) { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + async start(controller) { + const send = (data: object) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) + } + + try { + const user = await getCurrentUser() + if (!user) { + send({ step: 'error', message: 'Sign in to launch a preview.' }) + controller.close() + return + } + + const body = (await request.json()) as LaunchPreviewRequest + const { repoOwner, repoName, vercelToken } = body + + if (!vercelToken?.trim()) { + send({ step: 'error', message: 'Vercel token is required.' }) + controller.close() + return + } + + if (!repoOwner?.trim() || !repoName?.trim()) { + send({ step: 'error', message: 'Repository owner and name are required.' }) + controller.close() + return + } + + // Deduct credits before any AI/cloud work + const creditResult = await deductCredits( + user.id, + CREDITS.LAUNCH_PREVIEW_COST, + 'launch_preview', + { repoOwner, repoName }, + ) + if (!creditResult.success) { + send({ step: 'error', message: creditResult.error ?? 'Insufficient credits' }) + controller.close() + return + } + + // Step 1 — fetch package.json + send({ step: 'fetching', message: 'Fetching package.json from GitHub…' }) + const pkgFile = await getGitHubFile(user.access_token, repoOwner, repoName, 'package.json') + + // Step 2 — fix deps with Claude + if (pkgFile) { + send({ step: 'fixing', message: 'Updating dependencies with Claude…' }) + let fixedPkg = pkgFile.content + try { + const updated = await fixDependencies(pkgFile.content) + // Verify it's still valid JSON before pushing + JSON.parse(updated) + if (updated !== pkgFile.content) { + fixedPkg = updated + } + } catch { + // Keep original if Claude output is invalid + } + + if (fixedPkg !== pkgFile.content) { + send({ step: 'pushing', message: 'Pushing updated package.json…' }) + try { + await pushGitHubFile( + user.access_token, + repoOwner, + repoName, + 'package.json', + fixedPkg, + pkgFile.sha, + 'chore: update dependencies to latest stable versions', + ) + } catch { + // Non-fatal — continue with Vercel deployment + } + } else { + send({ step: 'pushing', message: 'Dependencies are up to date.' }) + } + } + + // Step 3 — create Vercel project + send({ step: 'creating', message: 'Creating Vercel project…' }) + const projectName = `${repoName}-preview-${Date.now()}`.slice(0, 52).replace(/[^a-z0-9-]/gi, '-').toLowerCase() + let project: { id: string; name: string } + try { + project = await createVercelProject(vercelToken, projectName, repoOwner, repoName) + } catch (e) { + send({ + step: 'error', + message: `Vercel project creation failed: ${e instanceof Error ? e.message : String(e)}. Make sure your Vercel account is connected to GitHub.`, + }) + controller.close() + return + } + + // Step 4 — trigger deployment + send({ step: 'deploying', message: 'Triggering Vercel deployment…' }) + let deploymentId: string + try { + const [repoId, defaultBranch] = await Promise.all([ + getGitHubRepoId(user.access_token, repoOwner, repoName), + getGitHubRepoDefaultBranch(user.access_token, repoOwner, repoName), + ]) + const deployment = await createVercelDeployment(vercelToken, project.name, repoId, defaultBranch) + deploymentId = deployment.id + } catch (e) { + send({ + step: 'error', + message: `Could not trigger deployment: ${e instanceof Error ? e.message : String(e)}`, + }) + controller.close() + return + } + + // Step 5 — poll until ready + send({ step: 'deploying', message: 'Building on Vercel… (this may take up to 90s)' }) + let previewUrl: string + try { + previewUrl = await pollDeployment(vercelToken, deploymentId) + } catch (e) { + send({ + step: 'error', + message: `Deployment failed: ${e instanceof Error ? e.message : String(e)}`, + }) + controller.close() + return + } + + send({ step: 'done', previewUrl }) + } catch (e) { + console.error('[launch-preview] unhandled error:', e) + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ step: 'error', message: 'An unexpected error occurred.' })}\n\n`), + ) + } finally { + controller.close() + } + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) +} diff --git a/app/api/setup/init-db/route.ts b/app/api/setup/init-db/route.ts index 76b4f3b..c27b57a 100644 --- a/app/api/setup/init-db/route.ts +++ b/app/api/setup/init-db/route.ts @@ -113,7 +113,7 @@ async function run() { github_id BIGINT NOT NULL UNIQUE, stripe_customer_id VARCHAR(255) UNIQUE, stripe_subscription_id VARCHAR(255) UNIQUE, - plan VARCHAR(50) DEFAULT 'free' CHECK (plan IN ('free', 'pro')), + plan VARCHAR(50) DEFAULT 'free' CHECK (plan IN ('free', 'byok', 'pro', 'scale')), status VARCHAR(50) DEFAULT 'active' CHECK (status IN ('active', 'past_due', 'canceled', 'trialing')), current_period_end TIMESTAMP WITH TIME ZONE, analyses_used_this_month INTEGER DEFAULT 0, @@ -133,6 +133,66 @@ async function run() { await sql`CREATE INDEX IF NOT EXISTS idx_analyses_status ON analyses(status)` await sql`CREATE INDEX IF NOT EXISTS idx_app_blueprints_analysis_id ON app_blueprints(analysis_id)` + // Gap tracking tables (migration 002 had invalid PostgreSQL syntax — created here instead) + await sql` + CREATE TABLE IF NOT EXISTS missing_file_gaps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + blueprint_id UUID NOT NULL REFERENCES app_blueprints(id) ON DELETE CASCADE, + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(512) NOT NULL, + purpose TEXT, + complexity VARCHAR(20) NOT NULL CHECK (complexity IN ('low', 'medium', 'high')), + estimated_hours NUMERIC(10, 2) NOT NULL DEFAULT 1.0, + category VARCHAR(50) NOT NULL CHECK (category IN ( + 'auth', 'api', 'ui', 'database', 'utils', 'config', 'other' + )), + dependencies JSONB DEFAULT '[]'::jsonb, + is_blocking BOOLEAN DEFAULT FALSE, + suggested_stub TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(blueprint_id, file_path) + ) + ` + + await sql` + CREATE TABLE IF NOT EXISTS completed_gaps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + gap_id UUID NOT NULL REFERENCES missing_file_gaps(id) ON DELETE CASCADE, + blueprint_id UUID NOT NULL REFERENCES app_blueprints(id) ON DELETE CASCADE, + completed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(gap_id, blueprint_id) + ) + ` + + await sql` + CREATE TABLE IF NOT EXISTS templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + blueprint_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + tech_stack JSONB NOT NULL DEFAULT '[]'::jsonb, + estimated_hours NUMERIC(10, 2) NOT NULL DEFAULT 4.0, + reuse_percentage NUMERIC(5, 2) NOT NULL DEFAULT 50, + total_files INTEGER NOT NULL DEFAULT 0, + missing_files INTEGER NOT NULL DEFAULT 0, + tier VARCHAR(50) NOT NULL CHECK (tier IN ('quick_start', 'standard', 'comprehensive')), + featured BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) + ` + + await sql`CREATE INDEX IF NOT EXISTS idx_missing_file_gaps_blueprint_id ON missing_file_gaps(blueprint_id)` + await sql`CREATE INDEX IF NOT EXISTS idx_missing_file_gaps_category ON missing_file_gaps(category)` + await sql`CREATE INDEX IF NOT EXISTS idx_missing_file_gaps_is_blocking ON missing_file_gaps(is_blocking)` + await sql`CREATE INDEX IF NOT EXISTS idx_missing_file_gaps_blueprint_complexity ON missing_file_gaps(blueprint_id, complexity)` + await sql`CREATE INDEX IF NOT EXISTS idx_completed_gaps_gap_id ON completed_gaps(gap_id)` + await sql`CREATE INDEX IF NOT EXISTS idx_completed_gaps_blueprint_id ON completed_gaps(blueprint_id)` + await sql`CREATE INDEX IF NOT EXISTS idx_templates_tier ON templates(tier)` + await sql`CREATE INDEX IF NOT EXISTS idx_templates_featured ON templates(featured)` + return NextResponse.json({ success: true, message: 'Database schema initialized successfully.' }) } catch (err) { console.error('[setup] DB init failed:', err) diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index 0d277cd..1b47ead 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -36,16 +36,18 @@ export async function POST(request: NextRequest) { const sub = await stripe.subscriptions.retrieve(session.subscription as string) const githubId = Number(sub.metadata.github_id || session.metadata?.github_id) const periodEnd = (sub as unknown as { current_period_end?: number }).current_period_end + // Use the plan stored in metadata; default to 'pro' if missing + const planName = (sub.metadata.plan === 'scale' ? 'scale' : 'pro') as 'pro' | 'scale' if (githubId) { await upsertSubscription({ github_id: githubId, stripe_customer_id: session.customer as string, stripe_subscription_id: sub.id, - plan: 'pro', - status: 'active', + plan: planName, + status: sub.status === 'trialing' ? 'trialing' : 'active', current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null, }) - + // Grant initial credits on signup try { const user = await getUserByGithubId(githubId) @@ -53,13 +55,13 @@ export async function POST(request: NextRequest) { await grantCredits( user.id, CREDITS.INITIAL_GRANT, - 'Pro plan signup bonus', + `${planName} plan signup bonus`, { stripe_customer_id: session.customer as string } ) - console.log(`[v0] Granted ${CREDITS.INITIAL_GRANT} credits to user ${user.id}`) + console.log(`[webhook] Granted ${CREDITS.INITIAL_GRANT} credits to user ${user.id} (${planName})`) } } catch (err) { - console.error('[v0] Failed to grant signup credits:', err) + console.error('[webhook] Failed to grant signup credits:', err) } } } @@ -71,10 +73,15 @@ export async function POST(request: NextRequest) { const existing = await getSubscriptionByStripeCustomerId(sub.customer as string) const periodEnd = (sub as unknown as { current_period_end?: number }).current_period_end if (existing) { - const isPro = sub.status === 'active' || sub.status === 'trialing' + const isActive = sub.status === 'active' || sub.status === 'trialing' + // Preserve existing plan name (pro/scale) when still active; downgrade to free on cancel + const metaPlan = sub.metadata?.plan as string | undefined + const planToSet = isActive + ? ((metaPlan === 'scale' ? 'scale' : existing.plan === 'scale' ? 'scale' : 'pro') as 'pro' | 'scale') + : 'free' as const await upsertSubscription({ github_id: existing.github_id, - plan: isPro ? 'pro' : 'free', + plan: planToSet, status: sub.status === 'active' ? 'active' : sub.status === 'past_due' ? 'past_due' : sub.status === 'trialing' ? 'trialing' @@ -117,10 +124,10 @@ export async function POST(request: NextRequest) { if (invoice.customer) { const existing = await getSubscriptionByStripeCustomerId(invoice.customer as string) if (existing) { - // Grant monthly renewal credits on successful payment (but only if not the initial invoice) + // Only grant renewal credits on recurring payments, not the initial subscription creation try { - // Check if this is a renewal (not the initial invoice by checking if invoice number > 1) - if (invoice.number && invoice.number !== '0001') { + const isRenewal = invoice.billing_reason === 'subscription_cycle' + if (isRenewal) { const user = await getUserByGithubId(existing.github_id) if (user) { await grantCredits( @@ -129,11 +136,11 @@ export async function POST(request: NextRequest) { 'Monthly subscription renewal', { invoice_id: invoice.id, stripe_customer_id: invoice.customer as string } ) - console.log(`[v0] Granted ${CREDITS.MONTHLY_GRANT} monthly renewal credits to user ${user.id}`) + console.log(`[webhook] Granted ${CREDITS.MONTHLY_GRANT} renewal credits to user ${user.id}`) } } } catch (err) { - console.error('[v0] Failed to grant renewal credits:', err) + console.error('[webhook] Failed to grant renewal credits:', err) } } } diff --git a/app/api/templates/generate/route.ts b/app/api/templates/generate/route.ts index e9262fa..3bfe9c2 100644 --- a/app/api/templates/generate/route.ts +++ b/app/api/templates/generate/route.ts @@ -1,8 +1,15 @@ import { NextRequest, NextResponse } from 'next/server' -import { createTemplate, getBlueprintsByAnalysis, getMissingGapsByBlueprint } from '@/lib/queries' +import { createTemplate, getMissingGapsByBlueprint } from '@/lib/queries' +import { getCurrentUser } from '@/lib/auth' +import { getCreditBalance, deductCredits, CREDITS } from '@/lib/credits' export async function POST(request: NextRequest) { try { + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'Sign in to create templates.' }, { status: 401 }) + } + const body = await request.json() const { name, @@ -19,23 +26,31 @@ export async function POST(request: NextRequest) { ) } - // Calculate aggregate metrics from blueprints - let totalFiles = 0 + const balance = await getCreditBalance(user.id) + if (balance < CREDITS.PATTERN_ANALYZER_COST) { + return NextResponse.json( + { error: `Insufficient credits. Required: ${CREDITS.PATTERN_ANALYZER_COST}, Available: ${balance}` }, + { status: 402 } + ) + } + + // Calculate aggregate metrics — best-effort, missing_file_gaps table may not exist yet let totalMissingFiles = 0 let totalEstimatedHours = 0 - let totalReuse = 0 - - for (const blueprintId of blueprintIds) { - const gaps = await getMissingGapsByBlueprint(blueprintId) - totalMissingFiles += gaps.length - totalEstimatedHours += gaps.reduce((sum, g) => sum + g.estimated_hours, 0) - - // Estimate reuse (would be pulled from blueprint in real system) - totalReuse += 60 // placeholder + const totalReuse = blueprintIds.length * 60 + + try { + for (const blueprintId of blueprintIds) { + const gaps = await getMissingGapsByBlueprint(blueprintId) + totalMissingFiles += gaps.length + totalEstimatedHours += gaps.reduce((sum, g) => sum + g.estimated_hours, 0) + } + } catch (gapError) { + console.error('[templates/generate] getMissingGapsByBlueprint failed:', gapError) + // missing_file_gaps table not yet created — metrics default to 0 } - const reusePercentage = (totalReuse / blueprintIds.length) - const totalAllFiles = totalFiles + totalMissingFiles + const reusePercentage = totalReuse / blueprintIds.length const template = await createTemplate({ name, @@ -44,17 +59,31 @@ export async function POST(request: NextRequest) { tech_stack: techStack || [], estimated_hours: Math.round(totalEstimatedHours), reuse_percentage: Math.round(reusePercentage), - total_files: totalAllFiles, + total_files: totalMissingFiles, missing_files: totalMissingFiles, tier: tier as 'quick_start' | 'standard' | 'comprehensive', featured: false, }) + try { + await deductCredits(user.id, CREDITS.PATTERN_ANALYZER_COST, 'pattern_analyzer', { action: 'create_template' }) + } catch (creditError) { + console.error('[templates/generate] credit deduction failed after successful creation:', creditError) + } + return NextResponse.json({ template }, { status: 201 }) } catch (error) { - console.error('[v0] Error generating template:', error) + console.error('[templates/generate] error:', error) + const msg = error instanceof Error ? error.message : String(error) + // If templates table doesn't exist, prompt the user to run setup + if (msg.includes('does not exist') || msg.includes('relation')) { + return NextResponse.json( + { error: 'Database tables missing — visit /api/setup/init-db once to initialize, then retry.' }, + { status: 500 } + ) + } return NextResponse.json( - { error: 'Failed to generate template' }, + { error: `Failed to generate template: ${msg}` }, { status: 500 } ) } diff --git a/app/dashboard/analyses/[id]/page.tsx b/app/dashboard/analyses/[id]/page.tsx index c862830..a93cc58 100644 --- a/app/dashboard/analyses/[id]/page.tsx +++ b/app/dashboard/analyses/[id]/page.tsx @@ -33,19 +33,23 @@ export default async function AnalysisDetailPage({ getRepositoriesForAnalysis(id), getBlueprintsByAnalysis(id), ]) + } catch (error) { + console.error('[analysis-detail] Failed to load analysis data:', error) + notFound() + } - if (user) { + if (user) { + try { const [subscription, viewedIds] = await Promise.all([ getSubscriptionByGithubId(user.github_id), getUserViewedBlueprintIds(user.id), ]) userPlan = subscription?.plan || 'free' viewedBlueprintIds = viewedIds - // Check if in trial via Stripe (subscription status = 'trialing') isTrialing = subscription?.status === 'trialing' + } catch (error) { + console.error('[analysis-detail] Failed to load subscription/views, using free defaults:', error) } - } catch { - notFound() } if (!analysis) { diff --git a/app/dashboard/analyses/page.tsx b/app/dashboard/analyses/page.tsx index bfedb16..1f11892 100644 --- a/app/dashboard/analyses/page.tsx +++ b/app/dashboard/analyses/page.tsx @@ -10,8 +10,8 @@ export default async function AnalysesPage() { try { analyses = await getAllAnalyses() repositories = await getAllRepositories() - } catch { - // Database not available + } catch (error) { + console.error('[analyses] Failed to load page data:', error) } return diff --git a/app/dashboard/billing/page.tsx b/app/dashboard/billing/page.tsx index d46016d..4d41f49 100644 --- a/app/dashboard/billing/page.tsx +++ b/app/dashboard/billing/page.tsx @@ -14,8 +14,8 @@ export default async function BillingPage() { try { subscription = await getSubscriptionByGithubId(user.github_id) blueprintViewCount = await countUserBlueprintViews(user.id) - } catch { - // DB or table not available yet + } catch (error) { + console.error('[billing] Failed to load subscription data:', error) } } diff --git a/app/dashboard/blueprints/page.tsx b/app/dashboard/blueprints/page.tsx index 5e10c87..c5426c9 100644 --- a/app/dashboard/blueprints/page.tsx +++ b/app/dashboard/blueprints/page.tsx @@ -47,10 +47,10 @@ export default async function BlueprintsPage() { try { blueprints = await getBlueprintsFromAnalyses() - } catch { - // Database not available + } catch (error) { + console.error('[blueprints] Failed to load blueprints:', error) } - + // Check if user is Pro const isPro = false // In production: check user.subscription_tier === 'pro' diff --git a/app/dashboard/built-apps/page.tsx b/app/dashboard/built-apps/page.tsx index f44ccbd..6cfe3c6 100644 --- a/app/dashboard/built-apps/page.tsx +++ b/app/dashboard/built-apps/page.tsx @@ -54,8 +54,8 @@ export default async function BuiltAppsPage() { try { detectedApps = await getDetectedApps() - } catch { - // Database not available + } catch (error) { + console.error('[built-apps] Failed to load apps:', error) } return ( diff --git a/app/dashboard/debt-scanner/page.tsx b/app/dashboard/debt-scanner/page.tsx new file mode 100644 index 0000000..16449bf --- /dev/null +++ b/app/dashboard/debt-scanner/page.tsx @@ -0,0 +1,16 @@ +import { getAllAnalyses, type Analysis } from '@/lib/queries' +import { DebtScannerClient } from '@/components/debt-scanner-client' + +export const dynamic = 'force-dynamic' + +export default async function DebtScannerPage() { + let analyses: Analysis[] = [] + try { + const all = await getAllAnalyses() + analyses = all.filter((a) => a.status === 'complete') + } catch (error) { + console.error('[debt-scanner] Failed to load analyses:', error) + } + + return +} diff --git a/app/dashboard/error.tsx b/app/dashboard/error.tsx new file mode 100644 index 0000000..29992aa --- /dev/null +++ b/app/dashboard/error.tsx @@ -0,0 +1,42 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { AlertCircle } from 'lucide-react' +import Link from 'next/link' +import { useEffect } from 'react' + +export default function DashboardError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error('[dashboard] error boundary caught:', error) + }, [error]) + + return ( +
+

+ Dashboard +

+
+ +
+
+

This panel could not load.

+

+ Try refreshing the panel. If it keeps happening, check your environment + variables on Vercel. +

+
+
+ + +
+
+ ) +} diff --git a/app/dashboard/gaps/page.tsx b/app/dashboard/gaps/page.tsx index 9e42a2b..c333c60 100644 --- a/app/dashboard/gaps/page.tsx +++ b/app/dashboard/gaps/page.tsx @@ -172,8 +172,8 @@ async function GapsDashboardContent() { getAllMissingGaps(), getGapSummary(), ]) - } catch { - // Database tables may not exist yet + } catch (error) { + console.error('[gaps] Failed to re-fetch gaps:', error) } const gapsByPriority = groupGapsByPriority(gaps) diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 4d400cf..c5c8818 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -14,7 +14,7 @@ export default async function DashboardLayout({ } return ( -
+
{children} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index b6735d2..5beacb0 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -15,8 +15,8 @@ export default async function DashboardPage() { repositories = await getAllRepositories() analyses = await getAllAnalyses() gapSummary = await getGapSummary() - } catch { - // Database not available yet + } catch (error) { + console.error('[dashboard] Failed to load page data:', error) } const completedAnalyses = analyses.filter((analysis) => analysis.status === 'complete') diff --git a/app/dashboard/pattern-analyzer/page.tsx b/app/dashboard/pattern-analyzer/page.tsx index 637dd1e..0556958 100644 --- a/app/dashboard/pattern-analyzer/page.tsx +++ b/app/dashboard/pattern-analyzer/page.tsx @@ -9,8 +9,8 @@ export default async function PatternAnalyzerPage() { try { const all = await getAllAnalyses() analyses = all.filter((a) => a.status === 'complete') - } catch { - // Database not available + } catch (error) { + console.error('[pattern-analyzer] Failed to load analyses:', error) } return diff --git a/app/dashboard/repositories/page.tsx b/app/dashboard/repositories/page.tsx index 9c63dd4..702c2f4 100644 --- a/app/dashboard/repositories/page.tsx +++ b/app/dashboard/repositories/page.tsx @@ -20,8 +20,8 @@ export default async function RepositoriesPage() { try { repositories = await getAllRepositories() - } catch { - // Database not available yet + } catch (error) { + console.error('[repositories] Failed to load repositories:', error) } return ( diff --git a/app/dashboard/templates/page.tsx b/app/dashboard/templates/page.tsx index 38d764b..0c424f1 100644 --- a/app/dashboard/templates/page.tsx +++ b/app/dashboard/templates/page.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { TemplateAssemblyCard } from '@/components/template-assembly-card' +import { CreateTemplateModal } from '@/components/create-template-modal' import { getAllTemplates, getFeaturedTemplates, type Template } from '@/lib/queries' export const dynamic = 'force-dynamic' @@ -42,29 +43,33 @@ async function TemplateHubContent() { if (setupRequired || !all.length) { return (
-
- - - -
-

Template Assembly Hub

-

Pre-built project combinations ready to assemble

+
+
+ + + +
+

Template Assembly Hub

+

Pre-built project combinations ready to assemble

+
+
-

No templates available yet

+

No templates yet

- Templates will be generated once you run an analysis on your repositories. + Create a template by combining blueprints from your analyses, or run an analysis to discover new blueprints.

- +
+ + +
) @@ -75,8 +80,8 @@ async function TemplateHubContent() { getFeaturedTemplates(), getAllTemplates(), ]) - } catch { - // Database tables may not exist yet + } catch (error) { + console.error('[templates] Failed to re-fetch templates:', error) } const nonFeatured = all.filter(t => !featured.some(f => f.id === t.id)) @@ -85,18 +90,21 @@ async function TemplateHubContent() {
{/* Header */}
-
- - - -
-

Template Assembly Hub

-

- Start building today from code you already have. Pre-configured templates combine your best pieces. -

+
+
+ + + +
+

Template Assembly Hub

+

+ Start building today from code you already have. Pre-configured templates combine your best pieces. +

+
+
{/* Feature Cards */} diff --git a/app/page.tsx b/app/page.tsx index 7b1fd74..df5d38d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,8 +2,8 @@ import Link from 'next/link' import { Button } from '@/components/ui/button' import { RepoFuseLogo3D } from '@/components/repofuse-logo-3d' import { NavDropdown } from '@/components/nav-dropdown' -import { Github, ArrowRight, AlertCircle, Shield, Zap, GitBranch, Rocket, Code2, Sparkles } from 'lucide-react' -import { LaunchSignupModal } from '@/components/launch-signup-modal' +import { PhoneSlideshow } from '@/components/phone-slideshow' +import { Github, ArrowRight, AlertCircle, Zap } from 'lucide-react' const ERROR_MESSAGES: Record = { auth_required: 'You must sign in to access the dashboard.', @@ -22,7 +22,7 @@ export default async function HomePage({ searchParams }: { searchParams: Promise const errorMessage = error ? ERROR_MESSAGES[error] ?? 'An unexpected error occurred.' : null return ( -
+
{errorMessage && (
@@ -30,195 +30,132 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
)} - {/* Animated scanlines overlay */} -
- {/* Noise overlay */} -
- {/* Launch Day Banner - Compelling Headline */} -
-
-
-
- -
-
-

- FULL LAUNCH -

-

- 5/12/2026 -

-
-
-
-

- First 1,000 Developers Get: -

-

- 14 Days Free OR 3 Analyses + 1 Blueprint -

-

- Lock in lifetime pricing. Link GitHub or GitLab today. -

-
-
-
- {/* Header */} -
+
- - + + + RepoFuse - -
{/* Hero Section */}
-
- {/* Animated grid background */} -
+
+ {/* Subtle radial glow */} +
+
+ +
+ {/* Trust badge */} +
+ + + AI-POWERED + + + The #1 Repo Intelligence Platform +
- {/* Glowing orbs */} -
-
-
+ {/* Headline */} +

+ Your repos are hiding + buildable apps +

-
-
- {/* Left content */} -
- {/* Badge */} -
- - Now in Public Beta -
- - {/* Heading */} -
-

- Everything your - - repos - have been - - - waiting - for - -

-
- - {/* Subheading */} -

- RepoFuse scans your connected GitHub or GitLab repos and surfaces buildable project ideas, detects hidden potential, and turns scattered code into your next big launch — automatically. -

+ {/* Subheading */} +

+ RepoFuse scans your GitHub and GitLab repos, surfaces project ideas, and turns scattered code into your next launch —{' '} + automatically. +

- {/* CTA Buttons */} -
- - - - -
+ {/* Primary CTA */} + + + Scan My Repos Free + - {/* Social proof */} -
- 2,400+ developers already on the waitlist -
+ {/* Sub-text */} +

+ Connect in seconds. No credit card required. +

+ + {/* Social proof */} +
+
+ {['#22d3ee','#a78bfa','#fb923c','#4ade80'].map((c, i) => ( +
+ ))}
+ 2,400+ developers already scanning +
- {/* Right: Terminal window */} -
-
- {/* Terminal header */} -
-
-
-
- repofuse — repo-scanner -
+ {/* Terminal preview */} +
+ {/* Window chrome */} +
+
+
+
+ RepoFuse Dashboard +
+ + Online +
+
- {/* Terminal body */} -
-
- $ - repofuse scan --org DealPatrol -
-
-
▸ Connecting to GitHub API...
-
✓ Found 14 repositories
-
▸ Analyzing code patterns...
-
-
-
📦 repo-app-architect
-
⚡ 3 buildable ideas detected
-
→ SaaS: AI Code Review Tool
-
→ Tool: Repo Health Dashboard
-
→ API: Webhook Automation Kit
-
-
-
▸ Generating project briefs...
-
-
- $ - -
-
+ {/* Content */} +
+
+ $ + repofuse scan --org DealPatrol --all-repos +
+
+
▸ Connecting to GitHub API...
+
✓ Found 14 repositories
+
▸ Analyzing code patterns & dependencies...
+
+
+
📦 repo-app-architect
+
⚡ 3 buildable ideas detected
+
→ SaaS: AI Code Review Tool
+
→ Tool: Repo Health Dashboard
+
→ API: Webhook Automation Kit
+
+
▸ Generating full project briefs...
+
+ $ +
@@ -226,69 +163,78 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
{/* Metrics Strip */} -
+
-
-
-

12k+

-

Repos Scanned

-
-
-

4.1k

-

Ideas Found

-
-
-

89%

-

Code Reuse

-
-
-

<30s

-

Analysis Time

+
+ {[ + { val: '12k+', label: 'Repos Scanned', color: 'text-cyan-400' }, + { val: '4.1k', label: 'Ideas Found', color: 'text-orange-400' }, + { val: '89%', label: 'Code Reuse', color: 'text-purple-400' }, + { val: '<30s', label: 'Analysis Time', color: 'text-cyan-400' }, + ].map((m) => ( +
+

{m.val}

+

{m.label}

+
+ ))} +
+
+
+ + {/* Phone Slideshow — See it in action */} +
+
+
+
+ SEE IT IN ACTION
+

+ From idea to GitHub
+ in under a minute +

+
+
+
+ {/* Before / After comparison */} +
+
+ Without RepoFuse: repos collecting dust, no launches. With RepoFuse: 7 buildable apps detected, full blueprints, push to GitHub in one click. +
+
+ {/* How It Works */} -
-
-
-
- +
+
+
+
HOW IT WORKS
-

Four steps to
- - buildable blueprints - +

+ Four steps to
+ buildable blueprints

-
- {/* Connecting line */} -
- +
{[ { num: '01', title: 'Connect', icon: '🔗', desc: 'OAuth in one click. Read-only access to your repos.' }, { num: '02', title: 'Scan', icon: '⚡', desc: 'AI analyzes structure, patterns, and dependencies.' }, { num: '03', title: 'Discover', icon: '💡', desc: 'Ideas surface instantly, ranked by viability.' }, - { num: '04', title: 'Build', icon: '🚀', desc: 'Get full briefs, stack recs, and MVP roadmaps.' } + { num: '04', title: 'Build', icon: '🚀', desc: 'Get full briefs, stack recs, and MVP roadmaps.' }, ].map((step, i) => ( -
-
- {/* Step number background */} -
-
- {step.num} -
-
-

{step.title}

-

{step.desc}

-
+
+
{step.icon}
+
{step.num}
+

{step.title}

+

{step.desc}

))}
@@ -297,78 +243,71 @@ export default async function HomePage({ searchParams }: { searchParams: Promise {/* Feature Grid */}
-
-
-
- +
+
+
FEATURES
-

Everything your repos
- - have been waiting for - +

+ Everything your repos
+ have been waiting for

-
+
{[ { icon: '⚡', title: 'AI Repo Scanner', desc: 'Deep analysis of your codebase structure and patterns in seconds.' }, { icon: '💡', title: 'Idea Surfacer', desc: 'Turns existing code into ranked, buildable project ideas.' }, { icon: '🔗', title: 'Multi-Repo Fusion', desc: 'Cross-reference patterns across all your repos simultaneously.' }, { icon: '📊', title: 'Health Dashboard', desc: 'Live metrics on code quality and technical debt.' }, { icon: '📋', title: 'Launch Briefs', desc: 'AI-generated product briefs for every detected idea.' }, - { icon: '🔒', title: 'Private by Default', desc: 'Your code never leaves your control.' } - ].map((feature, i) => ( -
-
{feature.icon}
-

{feature.title}

-

{feature.desc}

-
+ { icon: '🔒', title: 'Private by Default', desc: 'Your code never leaves your control.' }, + ].map((f, i) => ( +
+
{f.icon}
+

{f.title}

+

{f.desc}

))}
- {/* CTA Section */} -
-
-

+ {/* Bottom CTA */} +
+
+

Your next product is
- - already in your repos - + already in your repos

-

+

Join 2,400+ developers who've stopped guessing and started shipping.

- -

- no credit card required · read-only access + + + Start Scanning Now + + +

+ no credit card · read-only access · cancel anytime

{/* Footer */} -