Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/api/prisma/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
7 changes: 6 additions & 1 deletion apps/app/prisma/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
11 changes: 11 additions & 0 deletions apps/app/src/app/(app)/onboarding/[orgId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<PostPaymentOnboarding
organization={organization}
initialData={initialData}
userEmail={session.user.email}
hasOtherOrgs={otherOrgCount > 0}
/>
);
}
106 changes: 106 additions & 0 deletions apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts
Original file line number Diff line number Diff line change
@@ -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,
};
});
Original file line number Diff line number Diff line change
@@ -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 (
<Button
type="button"
variant="ghost"
className="text-muted-foreground"
onClick={() => setConfirming(true)}
>
Cancel
</Button>
);
}

return (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Delete this org?</span>
<Button
type="button"
variant="destructive"
size="sm"
disabled={cancelAction.isExecuting}
onClick={() => cancelAction.execute({ organizationId })}
>
{cancelAction.isExecuting ? 'Canceling...' : 'Yes, cancel'}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setConfirming(false)}
>
No
</Button>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
userEmail?: string;
hasOtherOrgs?: boolean;
}

export function PostPaymentOnboarding({
organization,
initialData = {},
userEmail,
hasOtherOrgs = false,
}: PostPaymentOnboardingProps) {
const {
stepIndex,
Expand Down Expand Up @@ -239,6 +242,10 @@ export function PostPaymentOnboarding({
)}
</AnimatePresence>
<div className="flex items-center gap-2 justify-end">
<CancelOnboardingButton
organizationId={organization.id}
hasOtherOrgs={hasOtherOrgs && !isOnboarding && !isFinalizing}
/>
<AnimatePresence>
{stepIndex > 0 && (
<motion.div
Expand Down
11 changes: 11 additions & 0 deletions apps/app/src/app/(app)/setup/[setupId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { MinimalHeader } from '@/components/layout/MinimalHeader';
import { auth } from '@/utils/auth';
import { db } from '@db/server';
import { Metadata } from 'next';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
Expand Down Expand Up @@ -41,6 +42,15 @@ export default async function SetupWithIdPage({ params, searchParams }: SetupPag
return redirect(`/invite/${inviteCode}`);
}

// Check if user has existing completed orgs (for cancel button)
const existingOrgCount = await db.member.count({
where: {
userId: user.id,
deactivated: false,
organization: { onboardingCompleted: true, hasAccess: true },
},
});

return (
<div className="flex flex-1 min-h-0">
{/* Form Section - Left Side */}
Expand All @@ -51,6 +61,7 @@ export default async function SetupWithIdPage({ params, searchParams }: SetupPag
setupId={setupId}
initialData={setupSession.formData}
currentStep={setupSession.currentStep}
hasOtherOrgs={existingOrgCount > 0}
/>
</div>

Expand Down
40 changes: 30 additions & 10 deletions apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand Down
Loading
Loading