From 260dcc9d8c7bebd3028664f447e634fd9649e639 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 24 May 2026 11:11:41 +0000 Subject: [PATCH] fix: address critical auth billing and data loss bugs Co-authored-by: Cole Collins --- app/api/analyses/[id]/run/route.ts | 6 +- app/api/app-idea-chat/route.ts | 30 +- app/api/auth/github/callback/route.ts | 3 +- app/api/auth/github/login/route.ts | 3 +- app/api/pattern-analyzer/route.ts | 36 ++- app/api/stripe/webhook/route.ts | 415 ++++++++++++-------------- lib/queries.ts | 15 +- lib/redirects.ts | 24 ++ 8 files changed, 292 insertions(+), 240 deletions(-) create mode 100644 lib/redirects.ts diff --git a/app/api/analyses/[id]/run/route.ts b/app/api/analyses/[id]/run/route.ts index 502feb4..ab9e480 100644 --- a/app/api/analyses/[id]/run/route.ts +++ b/app/api/analyses/[id]/run/route.ts @@ -13,7 +13,7 @@ import { updateAnalysisStatus, createRepoFile, createBlueprint, - deleteBlueprintsByAnalysis, + deleteBlueprintsByIds, getBlueprintsByAnalysis, getSubscriptionByGithubId, upsertSubscription, @@ -191,10 +191,10 @@ export async function POST( controller.close() return } + const existingBlueprintIds = (await getBlueprintsByAnalysis(id)).map((blueprint) => blueprint.id) // 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,6 +430,8 @@ Constraints: ai_explanation: bp.explanation, }) } + + await deleteBlueprintsByIds(existingBlueprintIds) } // Update to complete diff --git a/app/api/app-idea-chat/route.ts b/app/api/app-idea-chat/route.ts index d9b28da..48ef0ca 100644 --- a/app/api/app-idea-chat/route.ts +++ b/app/api/app-idea-chat/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 { @@ -44,7 +44,24 @@ export interface ChatMessage { content: string } +async function refundFailedChatCredits( + userId: string, + analysisId: string | undefined, + reason: string, +) { + try { + await refundCredits(userId, CREDITS.PATTERN_ANALYZER_COST, reason, { + analysisId, + feature: 'app_idea_chat', + }) + } catch (error) { + console.error('[app-idea-chat] failed to refund credits:', error) + } +} + export async function POST(request: NextRequest) { + let refundContext: { userId: string; analysisId?: string } | null = null + try { const user = await getCurrentUser() if (!user) { @@ -70,6 +87,7 @@ export async function POST(request: NextRequest) { if (!creditResult.success) { return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 }) } + refundContext = { userId: user.id, analysisId } // Optionally load codebase context let codebaseContext = '' @@ -164,12 +182,22 @@ Always respond with valid JSON only (no markdown fences): try { parsed = JSON.parse(jsonText) } catch { + await refundFailedChatCredits(user.id, analysisId, 'App Idea Chat returned an invalid AI response') + refundContext = null return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 }) } + refundContext = null return NextResponse.json(parsed) } catch (error) { console.error('[app-idea-chat] error:', error) + if (refundContext) { + await refundFailedChatCredits( + refundContext.userId, + refundContext.analysisId, + 'App Idea Chat failed before completing', + ) + } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/app/api/auth/github/callback/route.ts b/app/api/auth/github/callback/route.ts index bc36743..9892eab 100644 --- a/app/api/auth/github/callback/route.ts +++ b/app/api/auth/github/callback/route.ts @@ -3,6 +3,7 @@ import { cookies } from 'next/headers' import { getDb } from '@/lib/db' import { GITHUB_ACCESS_TOKEN_COOKIE } from '@/lib/auth' import { upsertSubscription } from '@/lib/queries' +import { DEFAULT_GITHUB_RETURN_TO, getSafeRedirectPath } from '@/lib/redirects' function getBaseUrl(request: NextRequest) { return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin @@ -21,7 +22,7 @@ export async function GET(request: NextRequest) { const errorDescription = searchParams.get('error_description') const cookieStore = await cookies() const savedState = cookieStore.get('github_oauth_state')?.value - const returnTo = cookieStore.get('github_oauth_return_to')?.value || '/dashboard/repositories?connected=github' + const returnTo = getSafeRedirectPath(cookieStore.get('github_oauth_return_to')?.value, DEFAULT_GITHUB_RETURN_TO) if (error) { console.error('[v0] GitHub returned OAuth error:', error, errorDescription) diff --git a/app/api/auth/github/login/route.ts b/app/api/auth/github/login/route.ts index d34bff3..d791292 100644 --- a/app/api/auth/github/login/route.ts +++ b/app/api/auth/github/login/route.ts @@ -1,5 +1,6 @@ import crypto from 'node:crypto' import { NextRequest, NextResponse } from 'next/server' +import { DEFAULT_GITHUB_RETURN_TO, getSafeRedirectPath } from '@/lib/redirects' function getBaseUrl(request: NextRequest) { return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin @@ -18,7 +19,7 @@ export async function GET(request: NextRequest) { const state = crypto.randomUUID() const redirectUri = `${getBaseUrl(request)}/api/auth/github/callback` - const returnTo = request.nextUrl.searchParams.get('returnTo') || '/dashboard/repositories?connected=github' + const returnTo = getSafeRedirectPath(request.nextUrl.searchParams.get('returnTo'), DEFAULT_GITHUB_RETURN_TO) const params = new URLSearchParams({ client_id: clientId, diff --git a/app/api/pattern-analyzer/route.ts b/app/api/pattern-analyzer/route.ts index 5100fce..8f1a07e 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 { @@ -41,7 +41,20 @@ export interface PatternAnalyzerResult { analysisId: string } +async function refundFailedPatternCredits(userId: string, analysisId: string, reason: string) { + try { + await refundCredits(userId, CREDITS.PATTERN_ANALYZER_COST, reason, { + analysisId, + feature: 'pattern_analyzer', + }) + } catch (error) { + console.error('[pattern-analyzer] failed to refund credits:', error) + } +} + export async function POST(request: NextRequest) { + let refundContext: { userId: string; analysisId: string } | null = null + try { const user = await getCurrentUser() if (!user) { @@ -54,11 +67,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'analysisId 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 }) - } - const analysis = await getAnalysisById(analysisId) if (!analysis) { return NextResponse.json({ error: 'Analysis not found' }, { status: 404 }) @@ -163,6 +171,12 @@ Respond ONLY with a valid JSON object (no markdown fences) matching this exact s ] }` + 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 }) + } + refundContext = { userId: user.id, analysisId } + const response = await getAnthropic().messages.create({ model: getAnthropicModel(), max_tokens: 4096, @@ -178,6 +192,8 @@ Respond ONLY with a valid JSON object (no markdown fences) matching this exact s try { parsed = JSON.parse(jsonText) } catch { + await refundFailedPatternCredits(user.id, analysisId, 'Pattern Analyzer returned an invalid AI response') + refundContext = null return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 }) } @@ -188,9 +204,17 @@ Respond ONLY with a valid JSON object (no markdown fences) matching this exact s analysisId, } + refundContext = null return NextResponse.json(result) } catch (error) { console.error('[pattern-analyzer] error:', error) + if (refundContext) { + await refundFailedPatternCredits( + refundContext.userId, + refundContext.analysisId, + 'Pattern Analyzer failed before completing', + ) + } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index cb8d6b9..020170c 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -1,268 +1,231 @@ -import { NextRequest, NextResponse } from "next/server"; -import Stripe from "stripe"; -import { createClient, type SupabaseClient } from "@supabase/supabase-js"; - -export const dynamic = "force-dynamic"; -export const runtime = "nodejs"; - -// ─── Lazy clients (avoid build-time evaluation) ────────────────────────────── +import { NextRequest, NextResponse } from 'next/server' +import { getStripe } from '@/lib/stripe' +import { + getSubscriptionByStripeCustomerId, + getUserByGithubId, + updateUserBilling, + upsertSubscription, +} from '@/lib/queries' +import { CREDITS, grantCredits } from '@/lib/credits' +import type Stripe from 'stripe' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['active', 'trialing']) + +function getPeriodEnd(subscription: Stripe.Subscription): string | null { + const periodEnd = (subscription as unknown as { current_period_end?: number }).current_period_end + return periodEnd ? new Date(periodEnd * 1000).toISOString() : null +} -let _stripe: Stripe | null = null; -function getStripeClient(): Stripe { - if (_stripe) return _stripe; - const key = process.env.STRIPE_SECRET_KEY; - if (!key) { - throw new Error("STRIPE_SECRET_KEY is not configured"); +function normalizeSubscriptionStatus(status: Stripe.Subscription.Status): 'active' | 'past_due' | 'canceled' | 'trialing' { + if (status === 'active' || status === 'trialing' || status === 'past_due') { + return status } - _stripe = new Stripe(key); - return _stripe; + + return 'canceled' } -let _supabase: SupabaseClient | null = null; -function getSupabaseClient(): SupabaseClient { - if (_supabase) return _supabase; - const url = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!url || !serviceKey) { - throw new Error("Supabase env vars are not configured"); +async function syncBillingState({ + githubId, + stripeCustomerId, + stripeSubscriptionId, + stripePriceId, + status, + currentPeriodEnd, +}: { + githubId: number + stripeCustomerId?: string | null + stripeSubscriptionId?: string | null + stripePriceId?: string | null + status: Stripe.Subscription.Status + currentPeriodEnd?: string | null +}) { + const plan = ACTIVE_SUBSCRIPTION_STATUSES.has(status) ? 'pro' : 'free' + const normalizedStatus = normalizeSubscriptionStatus(status) + + await upsertSubscription({ + github_id: githubId, + stripe_customer_id: stripeCustomerId ?? null, + stripe_subscription_id: stripeSubscriptionId ?? null, + plan, + status: normalizedStatus, + current_period_end: currentPeriodEnd ?? null, + }) + + const user = await getUserByGithubId(githubId) + if (user) { + await updateUserBilling(user.id, { + stripe_customer_id: stripeCustomerId ?? null, + stripe_subscription_id: stripeSubscriptionId ?? null, + stripe_price_id: stripePriceId ?? null, + plan_tier: plan, + subscription_status: normalizedStatus, + }) } - _supabase = createClient(url, serviceKey); - return _supabase; + + return user } -// ─── Helpers ────────────────────────────────────────────────────────────────── +async function grantSignupCredits(githubId: number, customerId: string) { + try { + const user = await getUserByGithubId(githubId) + if (!user) return -function getPlanFromPriceId(priceId: string): string { - const priceMap: Record = { - [process.env.STRIPE_PRICE_PRO_MONTHLY || ""]: "pro", - [process.env.STRIPE_PRICE_PRO_YEARLY || ""]: "pro", - [process.env.STRIPE_PRICE_STARTER_MONTHLY || ""]: "starter", - }; - return priceMap[priceId] || "free"; + await grantCredits(user.id, CREDITS.INITIAL_GRANT, 'Pro plan signup bonus', { + stripe_customer_id: customerId, + }) + } catch (error) { + console.error('[stripe-webhook] Failed to grant signup credits:', error) + } } -// ─── Webhook Handler ────────────────────────────────────────────────────────── +async function grantRenewalCredits(githubId: number, invoice: Stripe.Invoice) { + try { + if (!invoice.number || invoice.number === '0001') return -export async function POST(req: NextRequest) { - // 1. Get the raw body — required for Stripe signature verification - const body = await req.text(); - const sig = req.headers.get("stripe-signature"); + const user = await getUserByGithubId(githubId) + if (!user) return - if (!sig) { - console.error("⚠️ Missing stripe-signature header"); - return NextResponse.json({ error: "Missing signature" }, { status: 400 }); + await grantCredits(user.id, CREDITS.MONTHLY_GRANT, 'Monthly subscription renewal', { + invoice_id: invoice.id, + stripe_customer_id: invoice.customer as string, + }) + } catch (error) { + console.error('[stripe-webhook] Failed to grant renewal credits:', error) } +} - if (!process.env.STRIPE_WEBHOOK_SECRET) { - console.error("⚠️ STRIPE_WEBHOOK_SECRET is not set in environment variables"); - return NextResponse.json({ error: "Webhook secret not configured" }, { status: 500 }); +export async function POST(request: NextRequest) { + const body = await request.text() + const signature = request.headers.get('stripe-signature') + + if (!signature || !process.env.STRIPE_WEBHOOK_SECRET || !process.env.STRIPE_SECRET_KEY) { + return NextResponse.json({ error: 'Webhook not configured' }, { status: 400 }) } - // 2. Verify the event actually came from Stripe - let event: Stripe.Event; + let stripe: ReturnType try { - event = getStripeClient().webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Unknown error"; - console.error(`⚠️ Webhook signature verification failed: ${message}`); - return NextResponse.json({ error: `Webhook Error: ${message}` }, { status: 400 }); + stripe = getStripe() + } catch { + return NextResponse.json({ error: 'Stripe not configured' }, { status: 503 }) } - console.log(`✅ Stripe webhook received: ${event.type}`); + let event: Stripe.Event + try { + event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET) + } catch (error) { + console.error('[stripe-webhook] Signature verification failed:', error) + return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }) + } - // 3. Handle each event type try { switch (event.type) { - - // ── Checkout completed (first subscription purchase) ────────────────── - case "checkout.session.completed": { - const session = event.data.object as Stripe.Checkout.Session; - const customerId = session.customer as string; - const subscriptionId = session.subscription as string; - const customerEmail = session.customer_email || session.customer_details?.email; - - if (!customerEmail) break; - - await getSupabaseClient() - .from("subscriptions") - .upsert({ - stripe_customer_id: customerId, - stripe_subscription_id: subscriptionId, - email: customerEmail, - status: "active", - updated_at: new Date().toISOString(), - }, { onConflict: "email" }); - - // Also update the user's plan in the profiles table - await getSupabaseClient() - .from("profiles") - .update({ - stripe_customer_id: customerId, - subscription_status: "active", - }) - .eq("email", customerEmail); - - console.log(`✅ Checkout completed for ${customerEmail}`); - break; + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session + if (session.mode !== 'subscription' || !session.customer || !session.subscription) { + break + } + + const subscription = await stripe.subscriptions.retrieve(session.subscription as string) + const githubId = Number(subscription.metadata.github_id || session.metadata?.github_id) + if (!githubId) { + throw new Error(`Missing github_id metadata for checkout session ${session.id}`) + } + + await syncBillingState({ + githubId, + stripeCustomerId: session.customer as string, + stripeSubscriptionId: subscription.id, + stripePriceId: subscription.items.data[0]?.price.id ?? null, + status: subscription.status, + currentPeriodEnd: getPeriodEnd(subscription), + }) + await grantSignupCredits(githubId, session.customer as string) + break } - // ── Subscription created ─────────────────────────────────────────────── - case "customer.subscription.created": { - const subscription = event.data.object as Stripe.Subscription; - const customerId = subscription.customer as string; - const priceId = subscription.items.data[0]?.price.id; - const plan = getPlanFromPriceId(priceId); - - // Get customer email from Stripe - const customer = await getStripeClient().customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; - - await getSupabaseClient() - .from("subscriptions") - .upsert({ - stripe_customer_id: customerId, - stripe_subscription_id: subscription.id, - email, - status: subscription.status, - plan, - current_period_end: new Date((subscription as unknown as { current_period_end: number }).current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), - }, { onConflict: "stripe_subscription_id" }); - - await getSupabaseClient() - .from("profiles") - .update({ plan, subscription_status: subscription.status }) - .eq("email", email); - - console.log(`✅ Subscription created: ${email} → ${plan}`); - break; + case 'customer.subscription.created': + case 'customer.subscription.updated': { + const subscription = event.data.object as Stripe.Subscription + const existing = await getSubscriptionByStripeCustomerId(subscription.customer as string) + const githubId = Number(subscription.metadata.github_id || existing?.github_id) + if (!githubId) { + throw new Error(`Missing github_id for subscription ${subscription.id}`) + } + + await syncBillingState({ + githubId, + stripeCustomerId: subscription.customer as string, + stripeSubscriptionId: subscription.id, + stripePriceId: subscription.items.data[0]?.price.id ?? null, + status: subscription.status, + currentPeriodEnd: getPeriodEnd(subscription), + }) + break } - // ── Subscription updated (plan change, renewal, etc.) ───────────────── - case "customer.subscription.updated": { - const subscription = event.data.object as Stripe.Subscription; - const customerId = subscription.customer as string; - const priceId = subscription.items.data[0]?.price.id; - const plan = getPlanFromPriceId(priceId); - - const customer = await getStripeClient().customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; - - await getSupabaseClient() - .from("subscriptions") - .update({ - status: subscription.status, - plan, - current_period_end: new Date((subscription as unknown as { current_period_end: number }).current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), - }) - .eq("stripe_subscription_id", subscription.id); - - await getSupabaseClient() - .from("profiles") - .update({ plan, subscription_status: subscription.status }) - .eq("email", email); - - console.log(`✅ Subscription updated: ${email} → ${plan} (${subscription.status})`); - break; + case 'customer.subscription.deleted': { + const subscription = event.data.object as Stripe.Subscription + const existing = await getSubscriptionByStripeCustomerId(subscription.customer as string) + const githubId = Number(subscription.metadata.github_id || existing?.github_id) + if (!githubId) { + throw new Error(`Missing github_id for deleted subscription ${subscription.id}`) + } + + await syncBillingState({ + githubId, + stripeCustomerId: subscription.customer as string, + stripeSubscriptionId: subscription.id, + stripePriceId: subscription.items.data[0]?.price.id ?? null, + status: 'canceled', + currentPeriodEnd: getPeriodEnd(subscription), + }) + break } - // ── Subscription cancelled/deleted ──────────────────────────────────── - case "customer.subscription.deleted": { - const subscription = event.data.object as Stripe.Subscription; - const customerId = subscription.customer as string; - - const customer = await getStripeClient().customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice + if (!invoice.customer) break - if (!email) break; - - await getSupabaseClient() - .from("subscriptions") - .update({ - status: "canceled", - plan: "free", - updated_at: new Date().toISOString(), + const existing = await getSubscriptionByStripeCustomerId(invoice.customer as string) + if (existing) { + await upsertSubscription({ + github_id: existing.github_id, + status: 'past_due', + plan: 'free', }) - .eq("stripe_subscription_id", subscription.id); - - await getSupabaseClient() - .from("profiles") - .update({ plan: "free", subscription_status: "canceled" }) - .eq("email", email); - console.log(`✅ Subscription cancelled: ${email}`); - break; + const user = await getUserByGithubId(existing.github_id) + if (user) { + await updateUserBilling(user.id, { + plan_tier: 'free', + subscription_status: 'past_due', + }) + } + } + break } - // ── Invoice paid (recurring renewal) ───────────────────────────────── - case "invoice.payment_succeeded": { - const invoice = event.data.object as Stripe.Invoice; - const subscriptionId = (invoice as unknown as { subscription?: string }).subscription as string | undefined; - - if (!subscriptionId) break; - - // Update period end on renewal - const subscription = await getStripeClient().subscriptions.retrieve(subscriptionId); - await getSupabaseClient() - .from("subscriptions") - .update({ - status: "active", - current_period_end: new Date((subscription as unknown as { current_period_end: number }).current_period_end * 1000).toISOString(), - updated_at: new Date().toISOString(), - }) - .eq("stripe_subscription_id", subscriptionId); + case 'invoice.payment_succeeded': { + const invoice = event.data.object as Stripe.Invoice + if (!invoice.customer) break - console.log(`✅ Invoice paid for subscription: ${subscriptionId}`); - break; - } - - // ── Invoice payment failed ──────────────────────────────────────────── - case "invoice.payment_failed": { - const invoice = event.data.object as Stripe.Invoice; - const customerId = invoice.customer as string; - - const customer = await getStripeClient().customers.retrieve(customerId) as Stripe.Customer; - const email = customer.email; - - if (!email) break; - - await getSupabaseClient() - .from("subscriptions") - .update({ - status: "past_due", - updated_at: new Date().toISOString(), - }) - .eq("stripe_customer_id", customerId); - - await getSupabaseClient() - .from("profiles") - .update({ subscription_status: "past_due" }) - .eq("email", email); - - console.log(`⚠️ Payment failed for: ${email}`); - break; + const existing = await getSubscriptionByStripeCustomerId(invoice.customer as string) + if (existing) { + await grantRenewalCredits(existing.github_id, invoice) + } + break } default: - console.log(`ℹ️ Unhandled event type: ${event.type}`); + break } } catch (error) { - console.error("❌ Error processing webhook event:", error); - // Still return 200 so Stripe doesn't keep retrying for logic errors - return NextResponse.json({ received: true, warning: "Processing error" }, { status: 200 }); + console.error('[stripe-webhook] Handler error:', error) + return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 }) } - // 4. Always return 200 so Stripe knows we received the event - return NextResponse.json({ received: true }, { status: 200 }); + return NextResponse.json({ received: true }) } - -// ─── Block all other HTTP methods ───────────────────────────────────────────── -// This prevents the 405 error Stripe was getting before -export async function GET() { - return NextResponse.json({ error: "Method not allowed" }, { status: 405 }); -} \ No newline at end of file diff --git a/lib/queries.ts b/lib/queries.ts index 05e3402..074861b 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -350,15 +350,24 @@ export async function deleteBlueprintsByAnalysis(analysisId: string): Promise { + if (ids.length === 0) return + + const sql = getDb() + for (const id of ids) { + await sql`DELETE FROM app_blueprints WHERE id = ${id}` + } +} + export async function updateUserBilling(userId: string, data: UserBillingUpdate): Promise { const sql = getDb() await sql` UPDATE user_auth SET stripe_customer_id = COALESCE(${data.stripe_customer_id ?? null}, stripe_customer_id), - stripe_subscription_id = ${data.stripe_subscription_id ?? null}, - stripe_price_id = ${data.stripe_price_id ?? null}, + stripe_subscription_id = COALESCE(${data.stripe_subscription_id ?? null}, stripe_subscription_id), + stripe_price_id = COALESCE(${data.stripe_price_id ?? null}, stripe_price_id), plan_tier = COALESCE(${data.plan_tier ?? null}, plan_tier), - subscription_status = ${data.subscription_status ?? null}, + subscription_status = COALESCE(${data.subscription_status ?? null}, subscription_status), updated_at = CURRENT_TIMESTAMP WHERE id = ${userId} ` diff --git a/lib/redirects.ts b/lib/redirects.ts new file mode 100644 index 0000000..ef5553d --- /dev/null +++ b/lib/redirects.ts @@ -0,0 +1,24 @@ +export const DEFAULT_GITHUB_RETURN_TO = '/dashboard/repositories?connected=github' + +export function getSafeRedirectPath( + value: string | null | undefined, + fallback = DEFAULT_GITHUB_RETURN_TO, +): string { + const candidate = value?.trim() + + if (!candidate || !candidate.startsWith('/') || candidate.startsWith('//') || candidate.includes('\\')) { + return fallback + } + + try { + const parsed = new URL(candidate, 'http://repofuse.local') + + if (parsed.origin !== 'http://repofuse.local') { + return fallback + } + + return `${parsed.pathname}${parsed.search}${parsed.hash}` + } catch { + return fallback + } +}