From 38ed00d71af4ae1f05b779e564dd9a02cde4ce1b Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:22:05 +0000 Subject: [PATCH] fix: add server-side redirect for users with pending invites on onboarding page (#26829) * fix: add server-side redirect for users with pending invites on onboarding page When users sign up with an invite token, they were being redirected to /onboarding/getting-started (plan selection page) instead of going directly to /onboarding/personal/settings. This caused users to see the payment prompt for team/org plans before being redirected. This fix adds a server-side check in the /onboarding/getting-started page to redirect users with pending invites directly to the personal onboarding flow, preventing them from seeing the plan selection page. Also refactored onboardingUtils.ts to use MembershipRepository instead of direct prisma access. Co-Authored-By: sean@cal.com * test: add integration tests for hasPendingInviteByUserId method Co-Authored-By: sean@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../onboarding/getting-started/page.tsx | 16 +- packages/features/auth/lib/onboardingUtils.ts | 18 +- .../MembershipRepository.integration-test.ts | 164 ++++++++++++++++++ .../repositories/MembershipRepository.ts | 25 ++- 4 files changed, 196 insertions(+), 27 deletions(-) create mode 100644 packages/features/membership/repositories/MembershipRepository.integration-test.ts 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 e96495d6a8..31119e9348 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 @@ -1,12 +1,11 @@ +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import { APP_NAME } from "@calcom/lib/constants"; +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; import { _generateMetadata } from "app/_utils"; import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { APP_NAME } from "@calcom/lib/constants"; - -import { buildLegacyRequest } from "@lib/buildLegacyCtx"; - import { OnboardingView } from "~/onboarding/getting-started/onboarding-view"; export const generateMetadata = async () => { @@ -26,6 +25,13 @@ 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) { + return redirect("/onboarding/personal/settings"); + } + const userEmail = session.user.email || ""; return ; diff --git a/packages/features/auth/lib/onboardingUtils.ts b/packages/features/auth/lib/onboardingUtils.ts index d0ad21285d..1bbd78de85 100644 --- a/packages/features/auth/lib/onboardingUtils.ts +++ b/packages/features/auth/lib/onboardingUtils.ts @@ -1,5 +1,6 @@ import dayjs from "@calcom/dayjs"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { prisma } from "@calcom/prisma"; @@ -51,9 +52,8 @@ export async function checkOnboardingRedirect( const featuresRepository = new FeaturesRepository(prisma); if (options?.checkEmailVerification) { - const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally( - "email-verification" - ); + const emailVerificationEnabled = + await featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification"); if (!user.emailVerified && user.identityProvider === "CAL" && emailVerificationEnabled) { // User needs email verification, redirect to verification page @@ -64,17 +64,9 @@ export async function checkOnboardingRedirect( // Determine which onboarding path to use const onboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3"); - const pendingInvite = await prisma.membership.findFirst({ - where: { - userId: userId, - accepted: false, - }, - select: { - id: true, - }, - }); + const hasPendingInvite = await MembershipRepository.hasPendingInviteByUserId({ userId }); - if (pendingInvite && onboardingV3Enabled) { + if (hasPendingInvite && onboardingV3Enabled) { return "/onboarding/personal/settings"; } diff --git a/packages/features/membership/repositories/MembershipRepository.integration-test.ts b/packages/features/membership/repositories/MembershipRepository.integration-test.ts new file mode 100644 index 0000000000..79a7889d4e --- /dev/null +++ b/packages/features/membership/repositories/MembershipRepository.integration-test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; + +import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { MembershipRepository } from "./MembershipRepository"; + +const createdMembershipIds: number[] = []; +let testTeamId: number; +let createdTeamId: number | null = null; + +async function clearTestMemberships() { + if (createdMembershipIds.length > 0) { + await prisma.membership.deleteMany({ + where: { id: { in: createdMembershipIds } }, + }); + createdMembershipIds.length = 0; + } +} + +describe("MembershipRepository (Integration Tests)", () => { + beforeAll(async () => { + let testTeam = await prisma.team.findFirst({ + where: { slug: { not: null } }, + }); + + if (!testTeam) { + testTeam = await prisma.team.create({ + data: { + name: "Test Team for MembershipRepository", + slug: `test-team-membership-repo-${Date.now()}`, + }, + }); + createdTeamId = testTeam.id; + } + testTeamId = testTeam.id; + }); + + afterAll(async () => { + if (createdTeamId) { + await prisma.team.delete({ where: { id: createdTeamId } }); + } + }); + + afterEach(async () => { + await clearTestMemberships(); + }); + + describe("hasPendingInviteByUserId", () => { + it("should return true when user has a pending invite (accepted: false)", async () => { + const newUser = await prisma.user.create({ + data: { + email: `test-pending-invite-${Date.now()}@example.com`, + username: `test-pending-${Date.now()}`, + }, + }); + + const membership = await prisma.membership.create({ + data: { + userId: newUser.id, + teamId: testTeamId, + role: MembershipRole.MEMBER, + accepted: false, + }, + }); + createdMembershipIds.push(membership.id); + + const result = await MembershipRepository.hasPendingInviteByUserId({ userId: newUser.id }); + + expect(result).toBe(true); + + await prisma.membership.delete({ where: { id: membership.id } }); + createdMembershipIds.length = 0; + await prisma.user.delete({ where: { id: newUser.id } }); + }); + + it("should return false when user has no pending invites (all accepted)", async () => { + const newUser = await prisma.user.create({ + data: { + email: `test-accepted-invite-${Date.now()}@example.com`, + username: `test-accepted-${Date.now()}`, + }, + }); + + const membership = await prisma.membership.create({ + data: { + userId: newUser.id, + teamId: testTeamId, + role: MembershipRole.MEMBER, + accepted: true, + }, + }); + createdMembershipIds.push(membership.id); + + const result = await MembershipRepository.hasPendingInviteByUserId({ userId: newUser.id }); + + expect(result).toBe(false); + + await prisma.membership.delete({ where: { id: membership.id } }); + createdMembershipIds.length = 0; + await prisma.user.delete({ where: { id: newUser.id } }); + }); + + it("should return false when user has no memberships at all", async () => { + const newUser = await prisma.user.create({ + data: { + email: `test-no-membership-${Date.now()}@example.com`, + username: `test-no-membership-${Date.now()}`, + }, + }); + + const result = await MembershipRepository.hasPendingInviteByUserId({ userId: newUser.id }); + + expect(result).toBe(false); + + await prisma.user.delete({ where: { id: newUser.id } }); + }); + + it("should return true when user has both accepted and pending invites", async () => { + const newUser = await prisma.user.create({ + data: { + email: `test-mixed-invites-${Date.now()}@example.com`, + username: `test-mixed-${Date.now()}`, + }, + }); + + const team2 = await prisma.team.findFirst({ + where: { + slug: { not: null }, + id: { not: testTeamId }, + }, + }); + + const acceptedMembership = await prisma.membership.create({ + data: { + userId: newUser.id, + teamId: testTeamId, + role: MembershipRole.MEMBER, + accepted: true, + }, + }); + createdMembershipIds.push(acceptedMembership.id); + + if (team2) { + const pendingMembership = await prisma.membership.create({ + data: { + userId: newUser.id, + teamId: team2.id, + role: MembershipRole.MEMBER, + accepted: false, + }, + }); + createdMembershipIds.push(pendingMembership.id); + } + + const result = await MembershipRepository.hasPendingInviteByUserId({ userId: newUser.id }); + + expect(result).toBe(team2 ? true : false); + + await clearTestMemberships(); + await prisma.user.delete({ where: { id: newUser.id } }); + }); + }); +}); diff --git a/packages/features/membership/repositories/MembershipRepository.ts b/packages/features/membership/repositories/MembershipRepository.ts index 89b7351337..a84f8ea675 100644 --- a/packages/features/membership/repositories/MembershipRepository.ts +++ b/packages/features/membership/repositories/MembershipRepository.ts @@ -3,8 +3,8 @@ import { withSelectedCalendars } from "@calcom/features/users/repositories/UserR import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { eventTypeSelect } from "@calcom/lib/server/eventTypeSelect"; -import { availabilityUserSelect, prisma, type PrismaTransaction } from "@calcom/prisma"; -import type { Prisma, Membership, PrismaClient } from "@calcom/prisma/client"; +import { availabilityUserSelect, type PrismaTransaction, prisma } from "@calcom/prisma"; +import type { Membership, Prisma, PrismaClient } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; @@ -563,13 +563,7 @@ export class MembershipRepository { } // Two indexed lookups instead of JOIN with ILIKE (which bypasses index) - async hasAcceptedMembershipByEmail({ - email, - teamId, - }: { - email: string; - teamId: number; - }): Promise { + async hasAcceptedMembershipByEmail({ email, teamId }: { email: string; teamId: number }): Promise { const user = await this.prismaClient.user.findUnique({ where: { email: email.toLowerCase() }, select: { id: true }, @@ -586,4 +580,17 @@ export class MembershipRepository { return membership?.accepted ?? false; } + + static async hasPendingInviteByUserId({ userId }: { userId: number }): Promise { + const pendingInvite = await prisma.membership.findFirst({ + where: { + userId, + accepted: false, + }, + select: { + id: true, + }, + }); + return !!pendingInvite; + } }