diff --git a/apps/api/prisma/client.ts b/apps/api/prisma/client.ts index 573debfd25..21e833f75a 100644 --- a/apps/api/prisma/client.ts +++ b/apps/api/prisma/client.ts @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient { // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; const adapter = new PrismaPg({ connectionString: url, ssl }); - return new PrismaClient({ adapter }); + return new PrismaClient({ + adapter, + transactionOptions: { + timeout: 30000, + }, + }); } export const db = globalForPrisma.prisma || createPrismaClient(); diff --git a/apps/app/prisma/client.ts b/apps/app/prisma/client.ts index d48e37720f..169de23539 100644 --- a/apps/app/prisma/client.ts +++ b/apps/app/prisma/client.ts @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient { // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; const adapter = new PrismaPg({ connectionString: url, ssl }); - return new PrismaClient({ adapter }); + return new PrismaClient({ + adapter, + transactionOptions: { + timeout: 30000, + }, + }); } export const db = globalForPrisma.prisma || createPrismaClient(); diff --git a/apps/app/src/app/(app)/onboarding/[orgId]/page.tsx b/apps/app/src/app/(app)/onboarding/[orgId]/page.tsx index bf9588ca2c..49a103261b 100644 --- a/apps/app/src/app/(app)/onboarding/[orgId]/page.tsx +++ b/apps/app/src/app/(app)/onboarding/[orgId]/page.tsx @@ -109,12 +109,23 @@ export default async function OnboardingPage({ params }: OnboardingPageProps) { }); } + // Check if user has other completed orgs (for cancel button) + const otherOrgCount = await db.member.count({ + where: { + userId: session.user.id, + organizationId: { not: orgId }, + deactivated: false, + organization: { onboardingCompleted: true, hasAccess: true }, + }, + }); + // We'll use a modified version that starts at step 3 return ( 0} /> ); } diff --git a/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts new file mode 100644 index 0000000000..99c1722078 --- /dev/null +++ b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts @@ -0,0 +1,106 @@ +'use server'; + +import { authActionClientWithoutOrg } from '@/actions/safe-action'; +import { auth } from '@/utils/auth'; +import { db } from '@db/server'; +import { headers } from 'next/headers'; +import { z } from 'zod'; + +const cancelSchema = z.object({ + organizationId: z.string().min(1), +}); + +export const cancelOnboarding = authActionClientWithoutOrg + .inputSchema(cancelSchema) + .metadata({ + name: 'cancel-onboarding', + track: { + event: 'cancel-onboarding', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return { success: false, error: 'Not authorized.' }; + } + + // Verify the user owns this org and it's still incomplete + const member = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId: parsedInput.organizationId, + role: { contains: 'owner' }, + }, + include: { organization: { select: { onboardingCompleted: true } } }, + }); + + if (!member) { + return { success: false, error: 'Only the owner can cancel onboarding.' }; + } + + if (member.organization.onboardingCompleted) { + return { success: false, error: 'Cannot cancel a completed organization.' }; + } + + // Find a fallback org to switch to BEFORE deleting + const fallbackOrg = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId: { not: parsedInput.organizationId }, + deactivated: false, + organization: { + onboardingCompleted: true, + hasAccess: true, + }, + }, + select: { organizationId: true }, + orderBy: { createdAt: 'desc' }, + }); + + // Must have a fallback org — refuse to delete if there's nowhere to go. + // The UI guards this too, but a race condition could remove fallback orgs + // between page render and action execution. + if (!fallbackOrg) { + return { success: false, error: 'No other organization to switch to.' }; + } + + // Switch active org BEFORE deletion so the session never + // references a deleted org (even if the client redirect is slow). + try { + await auth.api.setActiveOrganization({ + headers: await headers(), + body: { organizationId: fallbackOrg.organizationId }, + }); + } catch (error) { + console.error('Failed to switch to fallback org:', error); + return { success: false, error: 'Failed to switch organization.' }; + } + + // Delete the incomplete org (cascade handles related records). + // If this fails, roll back the active org switch to keep state consistent. + try { + await db.organization.delete({ + where: { id: parsedInput.organizationId }, + }); + } catch (error) { + console.error('Failed to delete organization:', error); + try { + await auth.api.setActiveOrganization({ + headers: await headers(), + body: { organizationId: parsedInput.organizationId }, + }); + } catch (rollbackError) { + console.error('Failed to rollback active org switch:', rollbackError); + } + return { success: false, error: 'Failed to cancel onboarding.' }; + } + + return { + success: true, + fallbackOrgId: fallbackOrg?.organizationId ?? null, + }; + }); diff --git a/apps/app/src/app/(app)/onboarding/components/CancelOnboardingButton.tsx b/apps/app/src/app/(app)/onboarding/components/CancelOnboardingButton.tsx new file mode 100644 index 0000000000..12c6ed492a --- /dev/null +++ b/apps/app/src/app/(app)/onboarding/components/CancelOnboardingButton.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { Button } from '@trycompai/ui/button'; +import { useAction } from 'next-safe-action/hooks'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { cancelOnboarding } from '../actions/cancel-onboarding'; + +interface CancelOnboardingButtonProps { + organizationId: string; + hasOtherOrgs: boolean; +} + +export function CancelOnboardingButton({ + organizationId, + hasOtherOrgs, +}: CancelOnboardingButtonProps) { + const [confirming, setConfirming] = useState(false); + + const cancelAction = useAction(cancelOnboarding, { + onSuccess: ({ data }) => { + if (data?.success) { + const target = data.fallbackOrgId ? `/${data.fallbackOrgId}` : '/setup'; + window.location.assign(target); + } else { + toast.error(data?.error || 'Failed to cancel'); + setConfirming(false); + } + }, + onError: ({ error }) => { + toast.error(error.serverError || 'Failed to cancel'); + setConfirming(false); + }, + }); + + if (!hasOtherOrgs) return null; + + if (!confirming) { + return ( + + ); + } + + return ( +
+ Delete this org? + + +
+ ); +} diff --git a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx index 8f3968fc0b..4a9fda707f 100644 --- a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx +++ b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx @@ -11,17 +11,20 @@ import { AlertCircle, Loader2 } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import Balancer from 'react-wrap-balancer'; import { usePostPaymentOnboarding } from '../hooks/usePostPaymentOnboarding'; +import { CancelOnboardingButton } from './CancelOnboardingButton'; interface PostPaymentOnboardingProps { organization: Organization; initialData?: Record; userEmail?: string; + hasOtherOrgs?: boolean; } export function PostPaymentOnboarding({ organization, initialData = {}, userEmail, + hasOtherOrgs = false, }: PostPaymentOnboardingProps) { const { stepIndex, @@ -239,6 +242,10 @@ export function PostPaymentOnboarding({ )}
+ {stepIndex > 0 && ( {/* Form Section - Left Side */} @@ -51,6 +61,7 @@ export default async function SetupWithIdPage({ params, searchParams }: SetupPag setupId={setupId} initialData={setupSession.formData} currentStep={setupSession.currentStep} + hasOtherOrgs={existingOrgCount > 0} />
diff --git a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts index 6478ec53bd..3393e6df7c 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts @@ -27,6 +27,8 @@ export const createOrganizationMinimal = authActionClientWithoutOrg }, }) .action(async ({ parsedInput, ctx }) => { + let createdOrgId: string | undefined; + try { const session = await auth.api.getSession({ headers: await headers(), @@ -144,6 +146,7 @@ export const createOrganizationMinimal = authActionClientWithoutOrg }); const orgId = newOrg.id; + createdOrgId = orgId; // Get the member that was created with the organization (the owner) const ownerMember = await db.member.findFirst({ @@ -174,22 +177,28 @@ export const createOrganizationMinimal = authActionClientWithoutOrg }); } - // Set new org as active + // Set new org as active — after this point, the session references + // the org so we must NOT delete it on cleanup. await auth.api.setActiveOrganization({ headers: await headers(), body: { organizationId: orgId, }, }); - - // Revalidate paths - const headersList = await headers(); - let path = headersList.get('x-pathname') || headersList.get('referer') || ''; - path = path.replace(/\/[a-z]{2}\//, '/'); - - revalidatePath(path); - revalidatePath('/'); - revalidatePath('/setup'); + createdOrgId = undefined; // Org is fully initialized, disable cleanup + + // Revalidate paths (non-critical, don't let failures kill the flow) + try { + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + + revalidatePath(path); + revalidatePath('/'); + revalidatePath('/setup'); + } catch (revalidateError) { + console.error('Non-critical: failed to revalidate paths:', revalidateError); + } // NO JOB TRIGGERS - that happens after payment in complete-onboarding @@ -200,6 +209,17 @@ export const createOrganizationMinimal = authActionClientWithoutOrg } catch (error) { console.error('Error during minimal organization creation:', error); + // Clean up partially created org to prevent orphans on retry. + // Only runs if the org was created but setActiveOrganization hasn't + // succeeded yet (createdOrgId is cleared after activation). + if (createdOrgId) { + try { + await db.organization.delete({ where: { id: createdOrgId } }); + } catch (cleanupError) { + console.error('Failed to clean up org after creation error:', cleanupError); + } + } + if (error instanceof Error) { return { success: false, diff --git a/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx b/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx index 27b6190230..947850d579 100644 --- a/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx +++ b/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx @@ -11,6 +11,7 @@ interface OnboardingFormActionsProps { isOnboarding: boolean; // For the loader in the Finish button isCurrentStepValid: boolean; onPrefillAll?: () => void; + hasOtherOrgs?: boolean; } export function OnboardingFormActions({ @@ -21,6 +22,7 @@ export function OnboardingFormActions({ isOnboarding, isCurrentStepValid, onPrefillAll, + hasOtherOrgs = false, }: OnboardingFormActionsProps) { // Check if we're on localhost - use useState/useEffect to avoid hydration mismatch const [isLocalhost, setIsLocalhost] = useState(false); @@ -38,6 +40,17 @@ export function OnboardingFormActions({ return (
+ {hasOtherOrgs && ( + + )} {isLocalhost && onPrefillAll && stepIndex === 0 && (
diff --git a/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts b/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts index 0af292fe6c..9ac806e8b7 100644 --- a/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts +++ b/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts @@ -130,13 +130,15 @@ export function useOnboardingForm({ // Clear answers after successful creation setSavedAnswers({}); } else { - toast.error('Failed to create organization'); + console.error('Organization creation failed:', data?.error); + toast.error('Failed to create organization. Please try again.'); setIsFinalizing(false); setIsOnboarding(false); } }, - onError: () => { - toast.error('Failed to create organization'); + onError: ({ error }) => { + console.error('Organization creation error:', error.serverError); + toast.error('Failed to create organization. Please try again.'); setIsFinalizing(false); setIsOnboarding(false); }, diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx index f719a69acc..b243f55441 100644 --- a/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx +++ b/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx @@ -1,5 +1,6 @@ 'use client'; +import { CancelOnboardingButton } from '@/app/(app)/onboarding/components/CancelOnboardingButton'; import { Button } from '@trycompai/ui/button'; import { Card } from '@trycompai/ui/card'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@trycompai/ui/tooltip'; @@ -11,10 +12,12 @@ export function BookingStep({ company, orgId, hasAccess, + hasOtherOrgs = false, }: { company: string; orgId: string; hasAccess: boolean; + hasOtherOrgs?: boolean; }) { const [isCopied, setIsCopied] = useState(false); @@ -80,6 +83,14 @@ export function BookingStep({ + + {/* Cancel option */} +
+ +
diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx index ff50a68b59..d33ae89074 100644 --- a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx +++ b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx @@ -113,11 +113,26 @@ export default async function UpgradePage({ params }: PageProps) { redirect(`/${orgId}`); } + // Check if user has other completed orgs (for cancel button) + const otherOrgCount = await db.member.count({ + where: { + userId: authSession.user.id, + organizationId: { not: orgId }, + deactivated: false, + organization: { onboardingCompleted: true, hasAccess: true }, + }, + }); + return ( <>
- + 0} + />
); diff --git a/apps/framework-editor/prisma/client.ts b/apps/framework-editor/prisma/client.ts index 573debfd25..21e833f75a 100644 --- a/apps/framework-editor/prisma/client.ts +++ b/apps/framework-editor/prisma/client.ts @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient { // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; const adapter = new PrismaPg({ connectionString: url, ssl }); - return new PrismaClient({ adapter }); + return new PrismaClient({ + adapter, + transactionOptions: { + timeout: 30000, + }, + }); } export const db = globalForPrisma.prisma || createPrismaClient(); diff --git a/apps/portal/prisma/client.ts b/apps/portal/prisma/client.ts index d48e37720f..169de23539 100644 --- a/apps/portal/prisma/client.ts +++ b/apps/portal/prisma/client.ts @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient { // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; const adapter = new PrismaPg({ connectionString: url, ssl }); - return new PrismaClient({ adapter }); + return new PrismaClient({ + adapter, + transactionOptions: { + timeout: 30000, + }, + }); } export const db = globalForPrisma.prisma || createPrismaClient(); diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 573debfd25..21e833f75a 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient { // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; const adapter = new PrismaPg({ connectionString: url, ssl }); - return new PrismaClient({ adapter }); + return new PrismaClient({ + adapter, + transactionOptions: { + timeout: 30000, + }, + }); } export const db = globalForPrisma.prisma || createPrismaClient();