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..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 @@ -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,7 +25,14 @@ const ServerPage = async () => { return redirect("/auth/login"); } - const userEmail = session.user.email || ""; + // 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..7b6344fd87d591 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 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 } }); + }); + }); +}); diff --git a/packages/features/membership/repositories/MembershipRepository.ts b/packages/features/membership/repositories/MembershipRepository.ts index 89b73513371770..7bb88216b0bd04 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: true, + }, + select: { + id: true, + }, + }); + return !!pendingInvite; + } }