From 65f2e2a06f7fbeb4da49dd120158d7e77f32e900 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 11:11:18 +0000 Subject: [PATCH] Fix critical auth and credit regressions Co-authored-by: Cole Collins --- app/api/analyses/[id]/analyze/route.ts | 118 ++++++------ app/api/app-idea-chat/route.ts | 24 ++- app/api/build-app/route.ts | 130 ++++++++++--- app/api/code-completion/route.ts | 29 ++- app/api/generate-scaffold/route.ts | 78 ++++---- app/api/pattern-analyzer/route.ts | 32 +++- .../[id]/milestones/[milestoneId]/route.ts | 9 +- lib/credits.ts | 175 +++++++----------- lib/queries.ts | 40 +++- 9 files changed, 377 insertions(+), 258 deletions(-) diff --git a/app/api/analyses/[id]/analyze/route.ts b/app/api/analyses/[id]/analyze/route.ts index 65e9bec..a5fd1e0 100644 --- a/app/api/analyses/[id]/analyze/route.ts +++ b/app/api/analyses/[id]/analyze/route.ts @@ -1,6 +1,7 @@ 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' const model = 'openai/gpt-4-turbo' @@ -21,41 +22,47 @@ interface AppSuggestion { export async function POST(request: NextRequest) { try { - const { analysisId, selectedRepos, userId } = (await request.json()) as { + const user = await getCurrentUser() + if (!user?.id) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const { analysisId, selectedRepos } = (await request.json()) as { analysisId: string selectedRepos: SelectedRepository[] - userId: string } - // Check credit balance before proceeding - if (!userId) { - return NextResponse.json({ error: 'User ID required' }, { status: 401 }) + if (!analysisId || !Array.isArray(selectedRepos)) { + return NextResponse.json({ error: 'analysisId and selectedRepos are required' }, { status: 400 }) } - const currentBalance = await getCreditBalance(userId) - if (currentBalance < CREDITS.ANALYSIS_COST) { + const deductResult = await deductCredits(user.id, CREDITS.ANALYSIS_COST, 'analysis', { + analysisId, + selectedRepos: selectedRepos.map((r) => r.name), + }) + if (!deductResult.success) { return NextResponse.json( { error: 'Insufficient credits', required: CREDITS.ANALYSIS_COST, - available: 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 = {} + 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 - } + 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 +83,43 @@ 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), - }) - if (!deductResult.success) { - console.error('Failed to deduct credits:', deductResult.error) - return NextResponse.json( - { error: 'Failed to process analysis' }, - { status: 500 } - ) + const newBalance = deductResult.transaction?.balance_after || 0 + + return NextResponse.json({ + analysisId, + suggestions, + totalSuggestions: suggestions.length, + completeSuggestions: suggestions.filter((suggestion) => suggestion.is_complete).length, + creditsUsed: CREDITS.ANALYSIS_COST, + creditsRemaining: newBalance, + }) + } catch (error) { + await refundCredits(user.id, CREDITS.ANALYSIS_COST, 'Analysis failed', { + analysisId, + originalTransactionId: deductResult.transaction?.id, + }).catch((refundError) => { + console.error('Failed to refund analysis credits:', refundError) + }) + throw error } - - 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/app-idea-chat/route.ts b/app/api/app-idea-chat/route.ts index 64b439e..9ee7f4c 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' @@ -59,13 +59,17 @@ function parseAppIdeaChatResponse(raw: string): AppIdeaChatResponse { } export async function POST(request: NextRequest) { + let chargedUserId: string | undefined + let chargedTransactionId: string | undefined + let chargedAnalysisId: string | undefined + try { if (!isAiConfigured()) { return NextResponse.json({ error: aiConfigErrorMessage() }, { status: 503 }) } const user = await getCurrentUser() - if (!user) { + if (!user?.id) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) } @@ -96,6 +100,9 @@ export async function POST(request: NextRequest) { if (!creditResult.success) { return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 }) } + chargedUserId = user.id + chargedTransactionId = creditResult.transaction?.id + chargedAnalysisId = analysisId let codebaseContext = '' if (analysisId) { @@ -198,13 +205,14 @@ Always respond with valid JSON only (no markdown fences): try { parsed = parseAppIdeaChatResponse(raw) } catch { - return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 }) + throw new Error('Failed to parse AI response') } if (!parsed.reply?.trim()) { - return NextResponse.json({ error: 'Empty AI response' }, { status: 500 }) + throw new Error('Empty AI response') } + chargedUserId = undefined return NextResponse.json({ reply: parsed.reply, suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions : [], @@ -216,6 +224,14 @@ Always respond with valid JSON only (no markdown fences): if (error instanceof Error) { console.error('[v0] Stack:', error.stack) } + if (chargedUserId) { + await refundCredits(chargedUserId, CREDITS.PATTERN_ANALYZER_COST, 'App Idea Chat failed', { + analysisId: chargedAnalysisId, + originalTransactionId: chargedTransactionId, + }).catch((refundError) => { + console.error('[v0] Failed to refund app idea chat credits:', refundError) + }) + } return NextResponse.json({ error: errorMsg }, { status: 500 }) } } diff --git a/app/api/build-app/route.ts b/app/api/build-app/route.ts index 45c0852..9b06ce9 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 { CREDITS, deductCredits, refundCredits } 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 Array.from(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}`) } } @@ -201,12 +202,33 @@ export async function POST(request: NextRequest) { const send = (data: object) => { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) } + let streamClosed = false + const closeStream = () => { + if (!streamClosed) { + streamClosed = true + controller.close() + } + } + let chargedBuild = false + let buildTransactionId: string | undefined + let buildUserId: string | undefined + + const refundBuild = async (reason: string, metadata: Record = {}) => { + if (!chargedBuild || !buildUserId) return + chargedBuild = false + await refundCredits(buildUserId, CREDITS.BUILD_APP_COST, reason, { + originalTransactionId: buildTransactionId, + ...metadata, + }).catch((refundError) => { + console.error('[build-app] Failed to refund credits:', refundError) + }) + } try { const user = await getCurrentUser() - if (!user) { + if (!user?.id) { send({ step: 'error', message: 'Sign in before building an app.' }) - controller.close() + closeStream() return } @@ -216,20 +238,53 @@ 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() + closeStream() 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.' }) + closeStream() + return + } + if (!repoName?.trim()) { send({ step: 'error', message: 'Repository name is required.' }) - controller.close() + closeStream() + return + } + + if ( + !blueprint?.name || + !Array.isArray(blueprint.technologies) || + !Array.isArray(blueprint.existing_files) || + !Array.isArray(blueprint.missing_files) + ) { + send({ step: 'error', message: 'Blueprint payload is incomplete.' }) + closeStream() return } const cleanRepoName = repoName.trim().replace(/\s+/g, '-').toLowerCase() + const deductResult = await deductCredits(user.id, CREDITS.BUILD_APP_COST, 'build_app', { + platform, + repoName: cleanRepoName, + blueprintName: blueprint.name, + }) + if (!deductResult.success) { + send({ + step: 'error', + message: deductResult.error || `Build This App requires ${CREDITS.BUILD_APP_COST} credits.`, + }) + closeStream() + return + } + chargedBuild = true + buildUserId = user.id + buildTransactionId = deductResult.transaction?.id // Step 1 — determine files to generate const filesToGenerate = getFilesToGenerate(blueprint) @@ -264,11 +319,15 @@ export async function POST(request: NextRequest) { gitlabBranch = project.default_branch || 'main' } } catch (e) { + await refundBuild('Build repository creation failed', { + platform, + repoName: cleanRepoName, + }) 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() + closeStream() return } @@ -285,14 +344,40 @@ export async function POST(request: NextRequest) { 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` + await refundBuild('Build file generation failed', { + platform, + repoName: cleanRepoName, + path, + }) + send({ + step: 'error', + message: `Could not generate ${path}: ${e instanceof Error ? e.message : String(e)}`, + }) + closeStream() + return } // Push to platform - if (platform === 'github') { - await pushFileToGitHub(accessToken, user.github_username, cleanRepoName, path, content) - } else if (gitlabProjectId !== null) { - await pushFileToGitLab(accessToken, gitlabProjectId, gitlabBranch, path, content) + try { + if (platform === 'github') { + await pushFileToGitHub(accessToken, user.github_username, cleanRepoName, path, content) + } else if (gitlabProjectId !== null) { + await pushFileToGitLab(accessToken, gitlabProjectId, gitlabBranch, path, content) + } else { + throw new Error('GitLab project was not initialized') + } + } catch (e) { + await refundBuild('Build file push failed', { + platform, + repoName: cleanRepoName, + path, + }) + send({ + step: 'error', + message: e instanceof Error ? e.message : `Could not push ${path}`, + }) + closeStream() + return } pushed++ @@ -311,16 +396,19 @@ export async function POST(request: NextRequest) { message: `${pushed} files generated and pushed successfully.`, repoUrl, filesCreated: pushed, + creditsUsed: CREDITS.BUILD_APP_COST, }) + chargedBuild = false } catch (e) { console.error('[build-app] unhandled error:', e) + await refundBuild('Build failed unexpectedly') controller.enqueue( encoder.encode( `data: ${JSON.stringify({ step: 'error', message: 'An unexpected error occurred.' })}\n\n`, ), ) } finally { - controller.close() + closeStream() } }, }) diff --git a/app/api/code-completion/route.ts b/app/api/code-completion/route.ts index f7ede13..d69db16 100644 --- a/app/api/code-completion/route.ts +++ b/app/api/code-completion/route.ts @@ -5,6 +5,7 @@ import { batchGenerateCompletions, } from '@/lib/code-completion' import { getDb } from '@/lib/db' +import { getCurrentUser } from '@/lib/auth' const LANG_EXTENSIONS: Record = { python: ['.py'], @@ -24,18 +25,20 @@ const LANG_EXTENSIONS: Record = { shell: ['.sh', '.bash'], } -async function fetchSnippetsFromDb(language: string): Promise { +async function fetchSnippetsFromDb(language: string, userId: string): Promise { try { const sql = getDb() const extensions = LANG_EXTENSIONS[language.toLowerCase()] ?? [] if (extensions.length === 0) return [] const rows = await sql` - SELECT id, path, name, extension, ai_summary, reusability_score, exports, imports - FROM repo_files - WHERE extension = ANY(${extensions}::text[]) - AND ai_summary IS NOT NULL - ORDER BY reusability_score DESC + SELECT rf.id, rf.path, rf.name, rf.extension, rf.ai_summary, rf.reusability_score, rf.exports, rf.imports + FROM repo_files rf + INNER JOIN repositories r ON r.id = rf.repository_id + WHERE r.user_id = ${userId} + AND rf.extension = ANY(${extensions}::text[]) + AND rf.ai_summary IS NOT NULL + ORDER BY rf.reusability_score DESC LIMIT 20 ` @@ -63,6 +66,11 @@ async function fetchSnippetsFromDb(language: string): Promise { export async function POST(request: NextRequest) { try { + const user = await getCurrentUser() + if (!user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const body = await request.json() const { incompleteCode, codebaseSnippets = [], language = 'python' } = body @@ -75,7 +83,7 @@ export async function POST(request: NextRequest) { const snippets: CodeSnippet[] = codebaseSnippets.length > 0 ? codebaseSnippets - : await fetchSnippetsFromDb(language) + : await fetchSnippetsFromDb(language, user.id) const result = await generateRelevanceGuidedCompletion( incompleteCode, @@ -120,6 +128,11 @@ export async function POST(request: NextRequest) { */ export async function PUT(request: NextRequest) { try { + const user = await getCurrentUser() + if (!user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const body = await request.json() const { codeSnippets = [], codebaseSnippets = [], language = 'python' } = body @@ -132,7 +145,7 @@ export async function PUT(request: NextRequest) { const snippets: CodeSnippet[] = codebaseSnippets.length > 0 ? codebaseSnippets - : await fetchSnippetsFromDb(language) + : await fetchSnippetsFromDb(language, user.id) const results = await batchGenerateCompletions( codeSnippets, diff --git a/app/api/generate-scaffold/route.ts b/app/api/generate-scaffold/route.ts index 312c934..1dcb3b8 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,9 +11,9 @@ 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) { + if (!user?.id) { return NextResponse.json({ error: 'Sign in with GitHub to generate scaffolds.' }, { status: 401 }) } @@ -32,29 +32,31 @@ 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 to Pro to get unlimited scaffold generation with 3,000 monthly credits.', + }, + { status: 402 }, + ) } - const raw = await generateWithGateway({ - feature: 'scaffold', - userId: user.id, - maxOutputTokens: 8192, - messages: [ - { - role: 'user', - content: `Generate a complete project scaffold for "${appName}". + let scaffold + 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,12 +90,10 @@ 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() if (jsonStr.includes('```')) { @@ -116,27 +116,21 @@ Example structure: 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'}`) - } - - let creditsUsed = 0 - if (userId) { - const deductResult = await deductCredits(userId, CREDITS.SCAFFOLD_COST, 'scaffold', { + console.error('[scaffold] Generation error after charge:', e) + await refundCredits(user.id, CREDITS.SCAFFOLD_COST, 'Scaffold generation failed', { appName, - technologies, + originalTransactionId: deductResult.transaction?.id, + }).catch((refundError) => { + console.error('[scaffold] Failed to refund credits:', refundError) }) - - if (deductResult.success) { - creditsUsed = CREDITS.SCAFFOLD_COST - } + throw new Error(`Failed to generate scaffold: ${e instanceof Error ? e.message : 'Unknown error'}`) } return NextResponse.json({ success: true, scaffold, appName, - creditsUsed, + creditsUsed: CREDITS.SCAFFOLD_COST, }) } catch (error) { console.error('[scaffold] Generation error:', error) diff --git a/app/api/pattern-analyzer/route.ts b/app/api/pattern-analyzer/route.ts index 10447f4..8e0466e 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 { @@ -42,9 +42,13 @@ export interface PatternAnalyzerResult { } export async function POST(request: NextRequest) { + let chargedUserId: string | undefined + let chargedTransactionId: string | undefined + let chargedAnalysisId: string | undefined + try { const user = await getCurrentUser() - if (!user) { + if (!user?.id) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) } @@ -61,11 +65,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 }) @@ -127,6 +126,14 @@ export async function POST(request: NextRequest) { .join('\n') : 'No blueprints generated yet.' + const creditResult = await deductCredits(user.id, CREDITS.PATTERN_ANALYZER_COST, 'pattern_analyzer', { analysisId }) + if (!creditResult.success) { + return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 }) + } + chargedUserId = user.id + chargedTransactionId = creditResult.transaction?.id + chargedAnalysisId = analysisId + 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 @@ -185,7 +192,7 @@ Respond ONLY with a valid JSON object (no markdown fences) matching this exact s try { parsed = JSON.parse(jsonText) } catch { - return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 }) + throw new Error('Failed to parse AI response') } const result: PatternAnalyzerResult = { @@ -195,9 +202,18 @@ Respond ONLY with a valid JSON object (no markdown fences) matching this exact s analysisId, } + chargedUserId = undefined return NextResponse.json(result) } catch (error) { console.error('[pattern-analyzer] error:', error) + if (chargedUserId) { + await refundCredits(chargedUserId, CREDITS.PATTERN_ANALYZER_COST, 'Pattern Analyzer failed', { + analysisId: chargedAnalysisId, + originalTransactionId: chargedTransactionId, + }).catch((refundError) => { + console.error('[pattern-analyzer] Failed to refund credits:', refundError) + }) + } 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..f75fd26 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, id, user.id, Boolean(completed)) 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..1d9661c 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 = EXCLUDED.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,35 @@ 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, GREATEST(${monthlyAllowance} - current_balance, 0) AS top_up + FROM user_credits + WHERE user_id = ${userId} + FOR UPDATE + ), + updated AS ( + UPDATE user_credits uc + SET + current_balance = uc.current_balance + current_row.top_up, + total_granted = total_granted + current_row.top_up, + last_renewal_date = CURRENT_TIMESTAMP + FROM current_row + WHERE uc.user_id = ${userId} + RETURNING uc.current_balance, current_row.top_up + ) 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, 'renewal', ${reason}, ${JSON.stringify(metadata)}::jsonb, current_balance + FROM updated + WHERE top_up > 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 +161,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 +203,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..3bb5844 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -1044,27 +1044,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, + projectId: string, + userId: string, + completed: boolean, +): Promise { const sql = getDb() const result = completed ? await sql` - UPDATE project_milestones + UPDATE project_milestones pm SET completed = true, completed_at = NOW() - WHERE id = ${id} - RETURNING * + FROM projects p + WHERE pm.id = ${id} + AND pm.project_id = ${projectId} + AND p.id = pm.project_id + AND p.user_id = ${userId} + RETURNING pm.* ` : await sql` - UPDATE project_milestones + UPDATE project_milestones pm SET completed = false, completed_at = NULL - WHERE id = ${id} - RETURNING * + FROM projects p + WHERE pm.id = ${id} + AND pm.project_id = ${projectId} + AND p.id = pm.project_id + AND p.user_id = ${userId} + RETURNING pm.* ` 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 pm + USING projects p + WHERE pm.id = ${id} + AND pm.project_id = ${projectId} + AND p.id = pm.project_id + AND p.user_id = ${userId} + RETURNING pm.id + ` + return result.length > 0 } export async function seedDefaultMilestones(projectId: string): Promise {