Skip to content

Commit 0ec7eed

Browse files
authored
Merge pull request #2503 from trycompai/fix/onboarding-org-creation-timeout
fix(onboarding): fix org creation timeout and improve error handling
2 parents 3f530b7 + 438d371 commit 0ec7eed

File tree

16 files changed

+317
-20
lines changed

16 files changed

+317
-20
lines changed

apps/api/prisma/client.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient {
1919
// Strip sslmode from the connection string to avoid conflicts with the explicit ssl option
2020
const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl;
2121
const adapter = new PrismaPg({ connectionString: url, ssl });
22-
return new PrismaClient({ adapter });
22+
return new PrismaClient({
23+
adapter,
24+
transactionOptions: {
25+
timeout: 30000,
26+
},
27+
});
2328
}
2429

2530
export const db = globalForPrisma.prisma || createPrismaClient();

apps/app/prisma/client.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient {
1919
// Strip sslmode from the connection string to avoid conflicts with the explicit ssl option
2020
const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl;
2121
const adapter = new PrismaPg({ connectionString: url, ssl });
22-
return new PrismaClient({ adapter });
22+
return new PrismaClient({
23+
adapter,
24+
transactionOptions: {
25+
timeout: 30000,
26+
},
27+
});
2328
}
2429

2530
export const db = globalForPrisma.prisma || createPrismaClient();

apps/app/src/app/(app)/onboarding/[orgId]/page.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,23 @@ export default async function OnboardingPage({ params }: OnboardingPageProps) {
109109
});
110110
}
111111

112+
// Check if user has other completed orgs (for cancel button)
113+
const otherOrgCount = await db.member.count({
114+
where: {
115+
userId: session.user.id,
116+
organizationId: { not: orgId },
117+
deactivated: false,
118+
organization: { onboardingCompleted: true, hasAccess: true },
119+
},
120+
});
121+
112122
// We'll use a modified version that starts at step 3
113123
return (
114124
<PostPaymentOnboarding
115125
organization={organization}
116126
initialData={initialData}
117127
userEmail={session.user.email}
128+
hasOtherOrgs={otherOrgCount > 0}
118129
/>
119130
);
120131
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
'use server';
2+
3+
import { authActionClientWithoutOrg } from '@/actions/safe-action';
4+
import { auth } from '@/utils/auth';
5+
import { db } from '@db/server';
6+
import { headers } from 'next/headers';
7+
import { z } from 'zod';
8+
9+
const cancelSchema = z.object({
10+
organizationId: z.string().min(1),
11+
});
12+
13+
export const cancelOnboarding = authActionClientWithoutOrg
14+
.inputSchema(cancelSchema)
15+
.metadata({
16+
name: 'cancel-onboarding',
17+
track: {
18+
event: 'cancel-onboarding',
19+
channel: 'server',
20+
},
21+
})
22+
.action(async ({ parsedInput, ctx }) => {
23+
const session = await auth.api.getSession({
24+
headers: await headers(),
25+
});
26+
27+
if (!session) {
28+
return { success: false, error: 'Not authorized.' };
29+
}
30+
31+
// Verify the user owns this org and it's still incomplete
32+
const member = await db.member.findFirst({
33+
where: {
34+
userId: session.user.id,
35+
organizationId: parsedInput.organizationId,
36+
role: { contains: 'owner' },
37+
},
38+
include: { organization: { select: { onboardingCompleted: true } } },
39+
});
40+
41+
if (!member) {
42+
return { success: false, error: 'Only the owner can cancel onboarding.' };
43+
}
44+
45+
if (member.organization.onboardingCompleted) {
46+
return { success: false, error: 'Cannot cancel a completed organization.' };
47+
}
48+
49+
// Find a fallback org to switch to BEFORE deleting
50+
const fallbackOrg = await db.member.findFirst({
51+
where: {
52+
userId: session.user.id,
53+
organizationId: { not: parsedInput.organizationId },
54+
deactivated: false,
55+
organization: {
56+
onboardingCompleted: true,
57+
hasAccess: true,
58+
},
59+
},
60+
select: { organizationId: true },
61+
orderBy: { createdAt: 'desc' },
62+
});
63+
64+
// Must have a fallback org — refuse to delete if there's nowhere to go.
65+
// The UI guards this too, but a race condition could remove fallback orgs
66+
// between page render and action execution.
67+
if (!fallbackOrg) {
68+
return { success: false, error: 'No other organization to switch to.' };
69+
}
70+
71+
// Switch active org BEFORE deletion so the session never
72+
// references a deleted org (even if the client redirect is slow).
73+
try {
74+
await auth.api.setActiveOrganization({
75+
headers: await headers(),
76+
body: { organizationId: fallbackOrg.organizationId },
77+
});
78+
} catch (error) {
79+
console.error('Failed to switch to fallback org:', error);
80+
return { success: false, error: 'Failed to switch organization.' };
81+
}
82+
83+
// Delete the incomplete org (cascade handles related records).
84+
// If this fails, roll back the active org switch to keep state consistent.
85+
try {
86+
await db.organization.delete({
87+
where: { id: parsedInput.organizationId },
88+
});
89+
} catch (error) {
90+
console.error('Failed to delete organization:', error);
91+
try {
92+
await auth.api.setActiveOrganization({
93+
headers: await headers(),
94+
body: { organizationId: parsedInput.organizationId },
95+
});
96+
} catch (rollbackError) {
97+
console.error('Failed to rollback active org switch:', rollbackError);
98+
}
99+
return { success: false, error: 'Failed to cancel onboarding.' };
100+
}
101+
102+
return {
103+
success: true,
104+
fallbackOrgId: fallbackOrg?.organizationId ?? null,
105+
};
106+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
'use client';
2+
3+
import { Button } from '@trycompai/ui/button';
4+
import { useAction } from 'next-safe-action/hooks';
5+
import { useState } from 'react';
6+
import { toast } from 'sonner';
7+
import { cancelOnboarding } from '../actions/cancel-onboarding';
8+
9+
interface CancelOnboardingButtonProps {
10+
organizationId: string;
11+
hasOtherOrgs: boolean;
12+
}
13+
14+
export function CancelOnboardingButton({
15+
organizationId,
16+
hasOtherOrgs,
17+
}: CancelOnboardingButtonProps) {
18+
const [confirming, setConfirming] = useState(false);
19+
20+
const cancelAction = useAction(cancelOnboarding, {
21+
onSuccess: ({ data }) => {
22+
if (data?.success) {
23+
const target = data.fallbackOrgId ? `/${data.fallbackOrgId}` : '/setup';
24+
window.location.assign(target);
25+
} else {
26+
toast.error(data?.error || 'Failed to cancel');
27+
setConfirming(false);
28+
}
29+
},
30+
onError: ({ error }) => {
31+
toast.error(error.serverError || 'Failed to cancel');
32+
setConfirming(false);
33+
},
34+
});
35+
36+
if (!hasOtherOrgs) return null;
37+
38+
if (!confirming) {
39+
return (
40+
<Button
41+
type="button"
42+
variant="ghost"
43+
className="text-muted-foreground"
44+
onClick={() => setConfirming(true)}
45+
>
46+
Cancel
47+
</Button>
48+
);
49+
}
50+
51+
return (
52+
<div className="flex items-center gap-2">
53+
<span className="text-sm text-muted-foreground">Delete this org?</span>
54+
<Button
55+
type="button"
56+
variant="destructive"
57+
size="sm"
58+
disabled={cancelAction.isExecuting}
59+
onClick={() => cancelAction.execute({ organizationId })}
60+
>
61+
{cancelAction.isExecuting ? 'Canceling...' : 'Yes, cancel'}
62+
</Button>
63+
<Button
64+
type="button"
65+
variant="ghost"
66+
size="sm"
67+
onClick={() => setConfirming(false)}
68+
>
69+
No
70+
</Button>
71+
</div>
72+
);
73+
}

apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,20 @@ import { AlertCircle, Loader2 } from 'lucide-react';
1111
import { useCallback, useEffect, useMemo, useState } from 'react';
1212
import Balancer from 'react-wrap-balancer';
1313
import { usePostPaymentOnboarding } from '../hooks/usePostPaymentOnboarding';
14+
import { CancelOnboardingButton } from './CancelOnboardingButton';
1415

1516
interface PostPaymentOnboardingProps {
1617
organization: Organization;
1718
initialData?: Record<string, any>;
1819
userEmail?: string;
20+
hasOtherOrgs?: boolean;
1921
}
2022

2123
export function PostPaymentOnboarding({
2224
organization,
2325
initialData = {},
2426
userEmail,
27+
hasOtherOrgs = false,
2528
}: PostPaymentOnboardingProps) {
2629
const {
2730
stepIndex,
@@ -239,6 +242,10 @@ export function PostPaymentOnboarding({
239242
)}
240243
</AnimatePresence>
241244
<div className="flex items-center gap-2 justify-end">
245+
<CancelOnboardingButton
246+
organizationId={organization.id}
247+
hasOtherOrgs={hasOtherOrgs && !isOnboarding && !isFinalizing}
248+
/>
242249
<AnimatePresence>
243250
{stepIndex > 0 && (
244251
<motion.div

apps/app/src/app/(app)/setup/[setupId]/page.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { MinimalHeader } from '@/components/layout/MinimalHeader';
22
import { auth } from '@/utils/auth';
3+
import { db } from '@db/server';
34
import { Metadata } from 'next';
45
import { headers } from 'next/headers';
56
import { redirect } from 'next/navigation';
@@ -41,6 +42,15 @@ export default async function SetupWithIdPage({ params, searchParams }: SetupPag
4142
return redirect(`/invite/${inviteCode}`);
4243
}
4344

45+
// Check if user has existing completed orgs (for cancel button)
46+
const existingOrgCount = await db.member.count({
47+
where: {
48+
userId: user.id,
49+
deactivated: false,
50+
organization: { onboardingCompleted: true, hasAccess: true },
51+
},
52+
});
53+
4454
return (
4555
<div className="flex flex-1 min-h-0">
4656
{/* Form Section - Left Side */}
@@ -51,6 +61,7 @@ export default async function SetupWithIdPage({ params, searchParams }: SetupPag
5161
setupId={setupId}
5262
initialData={setupSession.formData}
5363
currentStep={setupSession.currentStep}
64+
hasOtherOrgs={existingOrgCount > 0}
5465
/>
5566
</div>
5667

apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export const createOrganizationMinimal = authActionClientWithoutOrg
2727
},
2828
})
2929
.action(async ({ parsedInput, ctx }) => {
30+
let createdOrgId: string | undefined;
31+
3032
try {
3133
const session = await auth.api.getSession({
3234
headers: await headers(),
@@ -144,6 +146,7 @@ export const createOrganizationMinimal = authActionClientWithoutOrg
144146
});
145147

146148
const orgId = newOrg.id;
149+
createdOrgId = orgId;
147150

148151
// Get the member that was created with the organization (the owner)
149152
const ownerMember = await db.member.findFirst({
@@ -174,22 +177,28 @@ export const createOrganizationMinimal = authActionClientWithoutOrg
174177
});
175178
}
176179

177-
// Set new org as active
180+
// Set new org as active — after this point, the session references
181+
// the org so we must NOT delete it on cleanup.
178182
await auth.api.setActiveOrganization({
179183
headers: await headers(),
180184
body: {
181185
organizationId: orgId,
182186
},
183187
});
184-
185-
// Revalidate paths
186-
const headersList = await headers();
187-
let path = headersList.get('x-pathname') || headersList.get('referer') || '';
188-
path = path.replace(/\/[a-z]{2}\//, '/');
189-
190-
revalidatePath(path);
191-
revalidatePath('/');
192-
revalidatePath('/setup');
188+
createdOrgId = undefined; // Org is fully initialized, disable cleanup
189+
190+
// Revalidate paths (non-critical, don't let failures kill the flow)
191+
try {
192+
const headersList = await headers();
193+
let path = headersList.get('x-pathname') || headersList.get('referer') || '';
194+
path = path.replace(/\/[a-z]{2}\//, '/');
195+
196+
revalidatePath(path);
197+
revalidatePath('/');
198+
revalidatePath('/setup');
199+
} catch (revalidateError) {
200+
console.error('Non-critical: failed to revalidate paths:', revalidateError);
201+
}
193202

194203
// NO JOB TRIGGERS - that happens after payment in complete-onboarding
195204

@@ -200,6 +209,17 @@ export const createOrganizationMinimal = authActionClientWithoutOrg
200209
} catch (error) {
201210
console.error('Error during minimal organization creation:', error);
202211

212+
// Clean up partially created org to prevent orphans on retry.
213+
// Only runs if the org was created but setActiveOrganization hasn't
214+
// succeeded yet (createdOrgId is cleared after activation).
215+
if (createdOrgId) {
216+
try {
217+
await db.organization.delete({ where: { id: createdOrgId } });
218+
} catch (cleanupError) {
219+
console.error('Failed to clean up org after creation error:', cleanupError);
220+
}
221+
}
222+
203223
if (error instanceof Error) {
204224
return {
205225
success: false,

0 commit comments

Comments
 (0)