Skip to content
Open
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) {
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) {
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,
},
Comment on lines +584 to +589

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Pending-invite lookup is filtering accepted memberships.

This method will return true for already-accepted memberships and false for actual pending invites, which breaks the redirect behavior and the new tests.

✅ Suggested fix
-  const pendingInvite = await prisma.membership.findFirst({
-    where: {
-      userId,
-      accepted: true,
-    },
-    select: {
-      id: true,
-    },
-  });
+  const pendingInvite = await prisma.membership.findFirst({
+    where: {
+      userId,
+      accepted: false,
+    },
+    select: {
+      id: true,
+    },
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
static async hasPendingInviteByUserId({ userId }: { userId: number }): Promise<boolean> {
const pendingInvite = await prisma.membership.findFirst({
where: {
userId,
accepted: true,
},
static async hasPendingInviteByUserId({ userId }: { userId: number }): Promise<boolean> {
const pendingInvite = await prisma.membership.findFirst({
where: {
userId,
accepted: false,
},
select: {
id: true,
},
});
🤖 Prompt for AI Agents
In `@packages/features/membership/repositories/MembershipRepository.ts` around
lines 584 - 589, The hasPendingInviteByUserId method is incorrectly filtering
for accepted: true so it matches accepted memberships instead of pending
invites; update the prisma.membership.findFirst query inside
hasPendingInviteByUserId to filter for pending invites (e.g., accepted: false or
the appropriate null/false value used elsewhere for pending) so the method
returns true only when an invite is actually pending.

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