diff --git a/apps/web/app/(use-page-wrapper)/onboarding/getting-started/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/getting-started/page.tsx index 31119e9348..e5b9eabcb7 100644 --- a/apps/web/app/(use-page-wrapper)/onboarding/getting-started/page.tsx +++ b/apps/web/app/(use-page-wrapper)/onboarding/getting-started/page.tsx @@ -25,10 +25,10 @@ const ServerPage = async () => { return redirect("/auth/login"); } - // If user has pending team invites, redirect them directly to personal onboarding - // This handles the case where users sign up with an invite token and are redirected here - const hasPendingInvite = await MembershipRepository.hasPendingInviteByUserId({ userId: session.user.id }); - if (hasPendingInvite) { + // If user has any team membership (pending or accepted), redirect them directly to personal onboarding + // This handles the case where users sign up with an invite token (membership is auto-accepted) + const hasTeamMembership = await MembershipRepository.hasAnyTeamMembershipByUserId({ userId: session.user.id }); + if (hasTeamMembership) { return redirect("/onboarding/personal/settings"); } diff --git a/apps/web/modules/billing/hooks/useHasPaidPlan.ts b/apps/web/modules/billing/hooks/useHasPaidPlan.ts index c469cb1b2b..75331241c0 100644 --- a/apps/web/modules/billing/hooks/useHasPaidPlan.ts +++ b/apps/web/modules/billing/hooks/useHasPaidPlan.ts @@ -25,6 +25,12 @@ export function useTeamInvites() { return { isPending: listInvites.isPending, listInvites: listInvites.data }; } +export function useHasTeamMembership() { + const { data, isPending } = trpc.viewer.teams.hasTeamMembership.useQuery(); + + return { isPending, hasTeamMembership: data?.hasTeamMembership ?? false }; +} + export function useHasTeamPlan() { const { data: hasTeamPlan, isPending } = trpc.viewer.teams.hasTeamPlan.useQuery(); diff --git a/apps/web/modules/onboarding/getting-started/onboarding-view.tsx b/apps/web/modules/onboarding/getting-started/onboarding-view.tsx index cef646d033..8598c774d4 100644 --- a/apps/web/modules/onboarding/getting-started/onboarding-view.tsx +++ b/apps/web/modules/onboarding/getting-started/onboarding-view.tsx @@ -12,7 +12,7 @@ import { Badge } from "@calcom/ui/components/badge"; import { Button } from "@calcom/ui/components/button"; import { type IconName } from "@calcom/ui/components/icon"; import { RadioAreaGroup } from "@calcom/ui/components/radio"; -import { useTeamInvites } from "@calcom/web/modules/billing/hooks/useHasPaidPlan"; +import { useHasTeamMembership } from "@calcom/web/modules/billing/hooks/useHasPaidPlan"; import { OnboardingCard } from "../components/OnboardingCard"; import { OnboardingLayout } from "../components/OnboardingLayout"; @@ -30,7 +30,7 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => { const { selectedPlan, setSelectedPlan, resetOnboardingPreservingPlan } = useOnboardingStore(); const previousPlanRef = useRef(null); const [isPending, startTransition] = useTransition(); - const { listInvites, isPending: isPendingInvites } = useTeamInvites(); + const { hasTeamMembership, isPending: isPendingMembership } = useHasTeamMembership(); // Reset onboarding data when visiting this page, but preserve the selected plan useEffect(() => { @@ -38,15 +38,16 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // If user has pending team invites, redirect them directly to personal onboarding + // If user has any team membership (pending or accepted), redirect them directly to personal onboarding + // This handles the case where users sign up with an invite token (membership is auto-accepted) useEffect(() => { - if (!isPendingInvites && listInvites && listInvites.length > 0) { + if (!isPendingMembership && hasTeamMembership) { setSelectedPlan("personal"); startTransition(() => { router.push("/onboarding/personal/settings"); }); } - }, [isPendingInvites, listInvites, router, setSelectedPlan]); + }, [isPendingMembership, hasTeamMembership, router, setSelectedPlan]); // Plan order mapping for determining direction const planOrder: Record = { @@ -130,8 +131,8 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => { const selectedPlanData = plans.find((plan) => plan.id === selectedPlan); - // Show loading state while checking for invites or if redirecting - if (isPendingInvites || (listInvites && listInvites.length > 0)) { + // Show loading state while checking for team membership or if redirecting + if (isPendingMembership || hasTeamMembership) { return ( diff --git a/packages/features/auth/lib/onboardingUtils.ts b/packages/features/auth/lib/onboardingUtils.ts index 1bbd78de85..83ca3ba567 100644 --- a/packages/features/auth/lib/onboardingUtils.ts +++ b/packages/features/auth/lib/onboardingUtils.ts @@ -64,9 +64,11 @@ export async function checkOnboardingRedirect( // Determine which onboarding path to use const onboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3"); - const hasPendingInvite = await MembershipRepository.hasPendingInviteByUserId({ userId }); + // Check for any team membership (pending or accepted) to handle users who signed up via invite token + // When users sign up with an invite token, the membership is auto-accepted + const hasTeamMembership = await MembershipRepository.hasAnyTeamMembershipByUserId({ userId }); - if (hasPendingInvite && onboardingV3Enabled) { + if (hasTeamMembership && onboardingV3Enabled) { return "/onboarding/personal/settings"; } diff --git a/packages/features/membership/repositories/MembershipRepository.ts b/packages/features/membership/repositories/MembershipRepository.ts index d9be388574..dfcc7dac98 100644 --- a/packages/features/membership/repositories/MembershipRepository.ts +++ b/packages/features/membership/repositories/MembershipRepository.ts @@ -593,4 +593,24 @@ export class MembershipRepository { }); return !!pendingInvite; } + + /** + * Checks if a user has any team membership (pending or accepted). + * Used during onboarding to detect users who signed up via invite token, + * where the membership is auto-accepted. + */ + static async hasAnyTeamMembershipByUserId({ userId }: { userId: number }): Promise { + const membership = await prisma.membership.findFirst({ + where: { + userId, + team: { + isOrganization: false, + }, + }, + select: { + id: true, + }, + }); + return !!membership; + } } diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx index 0d034ee1a4..023db2582f 100644 --- a/packages/trpc/server/routers/viewer/teams/_router.tsx +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -124,6 +124,10 @@ export const viewerTeamsRouter = router({ return handler(opts); }), hasTeamPlan, + hasTeamMembership: authedProcedure.query(async (opts) => { + const { default: handler } = await import("./hasTeamMembership.handler"); + return handler(opts); + }), listInvites: authedProcedure.query(async (opts) => { const { default: handler } = await import("./listInvites.handler"); return handler(opts); diff --git a/packages/trpc/server/routers/viewer/teams/hasTeamMembership.handler.ts b/packages/trpc/server/routers/viewer/teams/hasTeamMembership.handler.ts new file mode 100644 index 0000000000..d05c5c3bdc --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/hasTeamMembership.handler.ts @@ -0,0 +1,15 @@ +import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; + +type HasTeamMembershipOptions = { + ctx: { + user: { id: number }; + }; +}; + +export const hasTeamMembershipHandler = async ({ ctx }: HasTeamMembershipOptions) => { + const userId = ctx.user.id; + const hasTeamMembership = await MembershipRepository.hasAnyTeamMembershipByUserId({ userId }); + return { hasTeamMembership }; +}; + +export default hasTeamMembershipHandler;