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 <Sean@brydon.io>

* test: add integration tests for hasPendingInviteByUserId method

Co-Authored-By: sean@cal.com <Sean@brydon.io>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
sean-brydon
2026-01-14 09:22:05 -03:00
committed by GitHub
co-authored by sean@cal.com Devin AI
parent 2881961859
commit 38ed00d71a
4 changed files with 196 additions and 27 deletions
@@ -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 <OnboardingView userEmail={userEmail} />;
+5 -13
View File
@@ -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";
}
@@ -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 } });
});
});
});
@@ -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<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 },
@@ -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: false,
},
select: {
id: true,
},
});
return !!pendingInvite;
}
}