From 84e412e0e6c21da36b27c65db9e761fc372a78a3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:18:10 +0000 Subject: [PATCH 1/3] 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 --- .../onboarding/getting-started/page.tsx | 16 ++++++++---- packages/features/auth/lib/onboardingUtils.ts | 18 ++++--------- .../repositories/MembershipRepository.ts | 25 ++++++++++++------- 3 files changed, 32 insertions(+), 27 deletions(-) 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 e96495d6a80ef3..31119e9348c838 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,11 +1,10 @@ -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 { 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 { OnboardingView } from "~/onboarding/getting-started/onboarding-view"; @@ -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 d0ad21285d4f4e..1bbd78de8546a9 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.ts b/packages/features/membership/repositories/MembershipRepository.ts index 89b73513371770..a84f8ea675b187 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; + } } From a7bc25797ef236e83b3edae0ea6d3474feb9ab41 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:10:30 +0000 Subject: [PATCH 2/3] test: add integration tests for hasPendingInviteByUserId method Co-Authored-By: sean@cal.com --- .../MembershipRepository.integration-test.ts | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 packages/features/membership/repositories/MembershipRepository.integration-test.ts 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 00000000000000..79a7889d4e0e9d --- /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 } }); + }); + }); +}); From 9710b2235b0cfab896d7f989146128afbba5d979 Mon Sep 17 00:00:00 2001 From: tomerqodo Date: Sun, 25 Jan 2026 12:01:07 +0200 Subject: [PATCH 3/3] update pr --- .../app/(use-page-wrapper)/onboarding/getting-started/page.tsx | 2 +- packages/features/auth/lib/onboardingUtils.ts | 2 +- .../features/membership/repositories/MembershipRepository.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 31119e9348c838..8877bb660b6715 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 @@ -32,7 +32,7 @@ const ServerPage = async () => { return redirect("/onboarding/personal/settings"); } - const userEmail = session.user.email || ""; + const userEmail = session.user.email || ''; return ; }; diff --git a/packages/features/auth/lib/onboardingUtils.ts b/packages/features/auth/lib/onboardingUtils.ts index 1bbd78de8546a9..7b6344fd87d591 100644 --- a/packages/features/auth/lib/onboardingUtils.ts +++ b/packages/features/auth/lib/onboardingUtils.ts @@ -66,7 +66,7 @@ export async function checkOnboardingRedirect( const hasPendingInvite = await MembershipRepository.hasPendingInviteByUserId({ userId }); - if (hasPendingInvite && onboardingV3Enabled) { + if (hasPendingInvite || onboardingV3Enabled) { return "/onboarding/personal/settings"; } diff --git a/packages/features/membership/repositories/MembershipRepository.ts b/packages/features/membership/repositories/MembershipRepository.ts index a84f8ea675b187..7bb88216b0bd04 100644 --- a/packages/features/membership/repositories/MembershipRepository.ts +++ b/packages/features/membership/repositories/MembershipRepository.ts @@ -585,7 +585,7 @@ export class MembershipRepository { const pendingInvite = await prisma.membership.findFirst({ where: { userId, - accepted: false, + accepted: true, }, select: { id: true,