Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 <OnboardingView userEmail={userEmail} />;
};
Expand Down
18 changes: 5 additions & 13 deletions packages/features/auth/lib/onboardingUtils.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 Bug: Logic changed from && to ||, breaking onboarding redirect

The original condition was if (pendingInvite && onboardingV3Enabled) — meaning users are redirected to personal onboarding only when they have a pending invite AND onboarding-v3 is enabled. The refactored code changes this to if (hasPendingInvite || onboardingV3Enabled), which means:

  1. All users with onboardingV3Enabled will be redirected to /onboarding/personal/settings regardless of invite status, bypassing the getting-started flow entirely.
  2. Users with pending invites will be redirected even when onboarding-v3 is disabled.
  3. Line 73 is now dead code: return onboardingV3Enabled ? "/onboarding/getting-started" : "/getting-started" can never evaluate onboardingV3Enabled as true because that case is already caught by the || condition on line 69.

This is a significant behavioral regression that changes the onboarding flow for all users, not just those with pending invites.

Was this helpful? React with 👍 / 👎

Suggested change
if (hasPendingInvite || onboardingV3Enabled) {
if (hasPendingInvite && onboardingV3Enabled) {
  • Apply suggested fix

return "/onboarding/personal/settings";
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: Test silently passes without validating core behavior

The "mixed invites" test at line 119 wraps the pending membership creation in if (team2) and then asserts expect(result).toBe(team2 ? true : false). If no second team exists in the database, the test never creates a pending invite and simply asserts false — making it a duplicate of the "no pending invites" test.

This means the test can pass in environments where only one team exists without ever testing the "both accepted and pending invites" scenario. A test that silently degrades to testing a different scenario undermines confidence in the test suite.

The test should either:

  1. Create a second team explicitly (like beforeAll does for the first team), or
  2. Fail explicitly if the required precondition (team2 exists) is not met.

Was this helpful? React with 👍 / 👎

  • Apply suggested fix

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 } });
});
});
});
25 changes: 16 additions & 9 deletions packages/features/membership/repositories/MembershipRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<boolean> {
async hasAcceptedMembershipByEmail({ email, teamId }: { email: string; teamId: number }): Promise<boolean> {
const user = await this.prismaClient.user.findUnique({
where: { email: email.toLowerCase() },
select: { id: true },
Expand All @@ -586,4 +580,17 @@ export class MembershipRepository {

return membership?.accepted ?? false;
}

static async hasPendingInviteByUserId({ userId }: { userId: number }): Promise<boolean> {
const pendingInvite = await prisma.membership.findFirst({
where: {
userId,
accepted: true,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 Bug: hasPendingInviteByUserId queries accepted: true instead of false

The new hasPendingInviteByUserId method is supposed to check if a user has pending (unaccepted) invites, but the Prisma query filters for accepted: true instead of accepted: false. This means the method returns true when a user has accepted memberships and false when they actually have pending invites — the exact opposite of the intended behavior.

Impact: Users with pending team invites will NOT be redirected to the personal onboarding page (the entire point of this PR). Users who have already accepted team memberships WILL be incorrectly redirected away from the getting-started page. This completely inverts the business logic.

The original code in onboardingUtils.ts (before this PR) correctly queried accepted: false. The refactoring introduced this inversion.

Was this helpful? React with 👍 / 👎

Suggested change
accepted: true,
const pendingInvite = await prisma.membership.findFirst({
where: {
userId,
accepted: false,
},
select: {
id: true,
},
});
  • Apply suggested fix

},
select: {
id: true,
},
});
return !!pendingInvite;
}
}