From 532f96993172402d0f8c67deddede856aabac887 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 11:08:24 +0000 Subject: [PATCH] Fix critical auth and billing regressions Co-authored-by: Cole Collins --- app/api/analyses/[id]/run/route.ts | 16 +- app/api/build-app/route.ts | 101 +++++++---- app/api/code-completion/route.ts | 35 +++- app/api/generate-scaffold/route.ts | 115 ++++++------ .../[id]/milestones/[milestoneId]/route.ts | 9 +- lib/credits.ts | 164 +++++++----------- lib/queries.ts | 105 ++++++++++- 7 files changed, 322 insertions(+), 223 deletions(-) diff --git a/app/api/analyses/[id]/run/route.ts b/app/api/analyses/[id]/run/route.ts index 11a4675..50d8228 100644 --- a/app/api/analyses/[id]/run/route.ts +++ b/app/api/analyses/[id]/run/route.ts @@ -12,8 +12,7 @@ import { getRepositoriesForAnalysis, updateAnalysisStatus, createRepoFile, - createBlueprint, - deleteBlueprintsByAnalysis, + replaceBlueprintsForAnalysis, getBlueprintsByAnalysis, getSubscriptionByGithubId, upsertSubscription, @@ -200,7 +199,6 @@ export async function POST( // Update status to scanning await updateAnalysisStatus(id, 'scanning') - await deleteBlueprintsByAnalysis(id) send({ status: 'scanning', progress: 10 }) // Fetch file trees from GitHub for each repository @@ -430,10 +428,10 @@ For each app blueprint: .map((bp) => normalizeBlueprint(bp)) .sort((a, b) => getOpportunityScore(b) - getOpportunityScore(a)) - for (const bp of rankedBlueprints) { - await createBlueprint({ - analysis_id: id, - user_id: user.id, + await replaceBlueprintsForAnalysis( + id, + user.id, + rankedBlueprints.map((bp) => ({ name: bp.name.slice(0, 255), description: bp.description, app_type: bp.app_type?.slice(0, 100) ?? null, @@ -444,8 +442,8 @@ For each app blueprint: estimated_effort: getEffortEstimate(bp.complexity, bp.missing_files.length), technologies: bp.technologies, ai_explanation: bp.explanation, - }) - } + })), + ) } // Update to complete diff --git a/app/api/build-app/route.ts b/app/api/build-app/route.ts index 45c0852..dbb3bfa 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,26 @@ 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() + const addFile = (path: string, purpose: string) => { + const normalizedPath = path.trim() + if (normalizedPath && !files.has(normalizedPath)) { + files.set(normalizedPath, { path: normalizedPath, purpose }) + } + } // Missing files from the blueprint for (const f of blueprint.missing_files) { - files.push({ path: f.name, purpose: f.purpose }) + addFile(f.name, f.purpose) } // Standard project files - files.push({ path: 'README.md', purpose: 'Comprehensive setup, usage, and API documentation' }) - files.push({ path: 'package.json', purpose: 'Project dependencies and scripts for the tech stack' }) - files.push({ path: '.env.example', purpose: 'All required environment variables with placeholder values' }) - files.push({ path: '.gitignore', purpose: 'Gitignore file appropriate for this stack' }) + addFile('README.md', 'Comprehensive setup, usage, and API documentation') + addFile('package.json', 'Project dependencies and scripts for the tech stack') + addFile('.env.example', 'All required environment variables with placeholder values') + addFile('.gitignore', 'Gitignore file appropriate for this stack') - return files + return Array.from(files.values()) } async function createGitHubRepo( @@ -88,7 +95,7 @@ async function createGitHubRepo( body: JSON.stringify({ name: repoName, description, - private: false, + private: true, auto_init: false, }), }) @@ -128,7 +135,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 +197,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}`) } } @@ -198,9 +205,19 @@ export async function POST(request: NextRequest) { const encoder = new TextEncoder() const stream = new ReadableStream({ async start(controller) { + let chargedForBuild = false + let refundMetadata: Record = {} + let userIdForRefund = '' const send = (data: object) => { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) } + const refundBuildCredits = async (reason: string) => { + if (!chargedForBuild) return + chargedForBuild = false + await refundCredits(userIdForRefund, CREDITS.BUILD_APP_COST, reason, refundMetadata).catch((refundError) => { + console.error('[build-app] Failed to refund credits:', refundError) + }) + } try { const user = await getCurrentUser() @@ -230,6 +247,23 @@ export async function POST(request: NextRequest) { } const cleanRepoName = repoName.trim().replace(/\s+/g, '-').toLowerCase() + refundMetadata = { + platform, + repoName: cleanRepoName, + blueprintName: blueprint.name, + } + userIdForRefund = user.id + + const deductResult = await deductCredits(user.id, CREDITS.BUILD_APP_COST, 'build_app', refundMetadata) + if (!deductResult.success) { + send({ + step: 'error', + message: deductResult.error ?? 'Insufficient credits for Build This App.', + }) + controller.close() + return + } + chargedForBuild = true // Step 1 — determine files to generate const filesToGenerate = getFilesToGenerate(blueprint) @@ -245,31 +279,22 @@ export async function POST(request: NextRequest) { let gitlabProjectId: number | null = null let gitlabBranch = 'main' - try { - if (platform === 'github') { - repoUrl = await createGitHubRepo( - accessToken, - user.github_username, - cleanRepoName, - blueprint.description ?? blueprint.name, - ) - } else { - const project = await createGitLabProject( - accessToken, - cleanRepoName, - blueprint.description ?? blueprint.name, - ) - repoUrl = project.web_url - gitlabProjectId = project.id - gitlabBranch = project.default_branch || 'main' - } - } catch (e) { - send({ - step: 'error', - message: `Could not create repository: ${e instanceof Error ? e.message : String(e)}. Make sure you are connected to ${platform === 'github' ? 'GitHub' : 'GitLab'}.`, - }) - controller.close() - return + if (platform === 'github') { + repoUrl = await createGitHubRepo( + accessToken, + user.github_username, + cleanRepoName, + blueprint.description ?? blueprint.name, + ) + } else { + const project = await createGitLabProject( + accessToken, + cleanRepoName, + blueprint.description ?? blueprint.name, + ) + repoUrl = project.web_url + gitlabProjectId = project.id + gitlabBranch = project.default_branch || 'main' } send({ step: 'repo_created', message: 'Repository created. Generating and pushing files…', repoUrl }) @@ -285,7 +310,7 @@ 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` + throw new Error(`Failed to generate ${path}: ${e instanceof Error ? e.message : String(e)}`) } // Push to platform @@ -312,11 +337,13 @@ export async function POST(request: NextRequest) { repoUrl, filesCreated: pushed, }) + chargedForBuild = false } catch (e) { console.error('[build-app] unhandled error:', e) + await refundBuildCredits('Build This App failed') controller.enqueue( encoder.encode( - `data: ${JSON.stringify({ step: 'error', message: 'An unexpected error occurred.' })}\n\n`, + `data: ${JSON.stringify({ step: 'error', message: e instanceof Error ? e.message : 'An unexpected error occurred.' })}\n\n`, ), ) } finally { diff --git a/app/api/code-completion/route.ts b/app/api/code-completion/route.ts index f7ede13..11f4e36 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 + 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 @@ -129,10 +142,16 @@ export async function PUT(request: NextRequest) { { status: 400 } ) } + if (codeSnippets.length > 20) { + return NextResponse.json( + { error: 'A maximum of 20 snippets can be completed at once' }, + { status: 400 } + ) + } 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..d5508ba 100644 --- a/app/api/generate-scaffold/route.ts +++ b/app/api/generate-scaffold/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { aiConfigErrorMessage, generateWithGateway, isAiConfigured } from '@/lib/ai-gateway' -import { getCreditBalance, deductCredits, CREDITS } from '@/lib/credits' +import { deductCredits, refundCredits, CREDITS } from '@/lib/credits' import { getCurrentUser } from '@/lib/auth' import { getSubscriptionByGithubId, upsertSubscription } from '@/lib/queries' import { hasProAccess } from '@/lib/pro-access' @@ -11,7 +11,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: aiConfigErrorMessage() }, { status: 503 }) } - const { appName, description, technologies, existingFiles, missingFiles, userId } = await request.json() + const { appName, description, technologies, existingFiles, missingFiles } = await request.json() const user = await getCurrentUser() if (!user) { return NextResponse.json({ error: 'Sign in with GitHub to generate scaffolds.' }, { status: 401 }) @@ -32,29 +32,28 @@ 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 creditMetadata = { appName, technologies } + const deductResult = await deductCredits(user.id, CREDITS.SCAFFOLD_COST, 'scaffold', creditMetadata) + if (!deductResult.success) { + return NextResponse.json( + { + error: 'Insufficient credits', + required: CREDITS.SCAFFOLD_COST, + message: deductResult.error ?? 'Insufficient credits for scaffold generation.', + }, + { status: 402 }, + ) } - const raw = await generateWithGateway({ - feature: 'scaffold', - userId: user.id, - maxOutputTokens: 8192, - messages: [ - { - role: 'user', - content: `Generate a complete project scaffold for "${appName}". + try { + const raw = await generateWithGateway({ + feature: 'scaffold', + userId: user.id, + maxOutputTokens: 8192, + messages: [ + { + role: 'user', + content: `Generate a complete project scaffold for "${appName}". Description: ${description} Technologies: ${(technologies ?? []).join(', ')} @@ -88,56 +87,50 @@ Example structure: "src/index.ts": "import express from 'express'\\nconst app = express()\\napp.get('/', (req, res) => res.json({ status: 'ok' }))\\napp.listen(3000)" } }`, - }, - ], - }) + }, + ], + }) - let scaffold - try { - let jsonStr = raw.trim() + let scaffold + try { + let jsonStr = raw.trim() - if (jsonStr.includes('```')) { - const match = jsonStr.match(/```(?:json)?\s*([\s\S]*?)\s*```/) - if (match?.[1]) { - jsonStr = match[1].trim() + if (jsonStr.includes('```')) { + const match = jsonStr.match(/```(?:json)?\s*([\s\S]*?)\s*```/) + if (match?.[1]) { + jsonStr = match[1].trim() + } } - } - if (!jsonStr.startsWith('{')) { - const objMatch = jsonStr.match(/\{[\s\S]*\}/) - if (objMatch) { - jsonStr = objMatch[0] + if (!jsonStr.startsWith('{')) { + const objMatch = jsonStr.match(/\{[\s\S]*\}/) + if (objMatch) { + jsonStr = objMatch[0] + } } - } - scaffold = JSON.parse(jsonStr) + scaffold = JSON.parse(jsonStr) - if (!scaffold.structure && !scaffold.files) { - throw new Error('Invalid scaffold structure - missing required fields') + if (!scaffold.structure && !scaffold.files) { + throw new Error('Invalid scaffold structure - missing required fields') + } + } catch (e) { + console.error('[scaffold] Failed to parse AI response:', raw.slice(0, 500)) + throw new Error(`Failed to parse scaffold: ${e instanceof Error ? e.message : 'Invalid JSON'}`) } - } catch (e) { - console.error('[scaffold] Failed to parse AI response:', raw.slice(0, 500)) - throw new Error(`Failed to parse scaffold: ${e instanceof Error ? e.message : 'Invalid JSON'}`) - } - let creditsUsed = 0 - if (userId) { - const deductResult = await deductCredits(userId, CREDITS.SCAFFOLD_COST, 'scaffold', { + return NextResponse.json({ + success: true, + scaffold, appName, - technologies, + creditsUsed: CREDITS.SCAFFOLD_COST, }) - - if (deductResult.success) { - creditsUsed = CREDITS.SCAFFOLD_COST - } + } catch (error) { + await refundCredits(user.id, CREDITS.SCAFFOLD_COST, 'Scaffold generation failed', creditMetadata).catch((refundError) => { + console.error('[scaffold] Failed to refund credits:', refundError) + }) + throw error } - - return NextResponse.json({ - success: true, - scaffold, - appName, - creditsUsed, - }) } catch (error) { console.error('[scaffold] Generation error:', error) return NextResponse.json( diff --git a/app/api/projects/[id]/milestones/[milestoneId]/route.ts b/app/api/projects/[id]/milestones/[milestoneId]/route.ts index 0b04cb1..83d888c 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(id, milestoneId, 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(id, milestoneId, 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..48f7548 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -58,20 +58,11 @@ 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 * ` @@ -100,29 +91,23 @@ 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 * ` @@ -137,40 +122,34 @@ export async function renewMonthlyCredits( metadata: CreditMetadata = {} ): Promise { const sql = getDb() - const userCredits = await getOrCreateUserCredits(userId) - const topUpAmount = Math.max(0, monthlyAllowance - userCredits.current_balance) + await getOrCreateUserCredits(userId) - if (topUpAmount === 0) { - await sql` + const transaction = await sql` + WITH current AS ( + SELECT current_balance, GREATEST(${monthlyAllowance} - current_balance, 0) AS top_up_amount + FROM user_credits + WHERE user_id = ${userId} + ), + updated AS ( UPDATE user_credits - SET last_renewal_date = CURRENT_TIMESTAMP + SET + current_balance = user_credits.current_balance + current.top_up_amount, + total_granted = total_granted + current.top_up_amount, + last_renewal_date = CURRENT_TIMESTAMP + FROM current 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} - ` - - const transaction = await sql` + RETURNING user_credits.current_balance, current.top_up_amount + ) INSERT INTO credit_transactions ( user_id, amount, transaction_type, reason, metadata, balance_after ) - VALUES ( - ${userId}, ${topUpAmount}, 'renewal', ${reason}, ${JSON.stringify(metadata)}::jsonb, ${newBalance} - ) + SELECT ${userId}, top_up_amount, 'renewal', ${reason}, ${JSON.stringify(metadata)}::jsonb, current_balance + FROM updated + WHERE top_up_amount > 0 RETURNING * ` - return transaction[0] as CreditTransaction + return (transaction[0] as CreditTransaction) || null } // Deduct credits (for analysis, scaffold, build_app, or pattern_analyzer) @@ -181,40 +160,33 @@ 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, @@ -230,26 +202,20 @@ 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 * ` diff --git a/lib/queries.ts b/lib/queries.ts index 7dd365e..8e786d5 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -74,6 +74,8 @@ export interface AppBlueprint { created_at: string } +type BlueprintInsert = Omit + // Subscription types & queries export interface Subscription { id: string @@ -424,6 +426,71 @@ export async function createBlueprint(data: { return result[0] as AppBlueprint } +export async function replaceBlueprintsForAnalysis( + analysisId: string, + userId: string, + blueprints: Omit[], +): Promise { + if (blueprints.length === 0) return [] + + const sql = getDb() + const rows = blueprints.map((blueprint) => ({ + name: blueprint.name, + description: blueprint.description, + app_type: blueprint.app_type, + complexity: blueprint.complexity, + reuse_percentage: blueprint.reuse_percentage, + existing_files: blueprint.existing_files, + missing_files: blueprint.missing_files, + estimated_effort: blueprint.estimated_effort, + technologies: blueprint.technologies, + ai_explanation: blueprint.ai_explanation, + })) + + const result = await sql` + WITH incoming AS ( + SELECT * + FROM jsonb_to_recordset(${JSON.stringify(rows)}::jsonb) AS x( + name text, + description text, + app_type text, + complexity text, + reuse_percentage numeric, + existing_files jsonb, + missing_files jsonb, + estimated_effort text, + technologies jsonb, + ai_explanation text + ) + ), + deleted AS ( + DELETE FROM app_blueprints + WHERE analysis_id = ${analysisId} AND user_id = ${userId} + ) + INSERT INTO app_blueprints ( + analysis_id, user_id, name, description, app_type, complexity, reuse_percentage, + existing_files, missing_files, estimated_effort, technologies, ai_explanation + ) + SELECT + ${analysisId}, + ${userId}, + name, + description, + app_type, + complexity, + reuse_percentage, + COALESCE(existing_files, '[]'::jsonb), + COALESCE(missing_files, '[]'::jsonb), + estimated_effort, + COALESCE(technologies, '[]'::jsonb), + ai_explanation + FROM incoming + RETURNING * + ` + + return result as AppBlueprint[] +} + // Gap & Template types export interface MissingFileGap { id: string @@ -1044,27 +1111,55 @@ export async function createMilestone(data: { return result[0] as ProjectMilestone } -export async function toggleMilestone(id: string, completed: boolean): Promise { +export async function toggleMilestone( + projectId: string, + milestoneId: string, + userId: string, + completed: boolean, +): Promise { const sql = getDb() const result = completed ? await sql` UPDATE project_milestones SET completed = true, completed_at = NOW() - WHERE id = ${id} + WHERE id = ${milestoneId} + AND project_id = ${projectId} + AND EXISTS ( + SELECT 1 FROM projects + WHERE projects.id = project_milestones.project_id + AND projects.user_id = ${userId} + ) RETURNING * ` : await sql` UPDATE project_milestones SET completed = false, completed_at = NULL - WHERE id = ${id} + WHERE id = ${milestoneId} + AND project_id = ${projectId} + AND EXISTS ( + SELECT 1 FROM projects + WHERE projects.id = project_milestones.project_id + AND projects.user_id = ${userId} + ) RETURNING * ` return (result[0] as ProjectMilestone) || null } -export async function deleteMilestone(id: string): Promise { +export async function deleteMilestone(projectId: string, milestoneId: string, userId: string): Promise { const sql = getDb() - await sql`DELETE FROM project_milestones WHERE id = ${id}` + const result = await sql` + DELETE FROM project_milestones + WHERE id = ${milestoneId} + AND project_id = ${projectId} + AND EXISTS ( + SELECT 1 FROM projects + WHERE projects.id = project_milestones.project_id + AND projects.user_id = ${userId} + ) + RETURNING id + ` + return result.length > 0 } export async function seedDefaultMilestones(projectId: string): Promise {