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:
co-authored by
sean@cal.com
Devin AI
parent
2881961859
commit
38ed00d71a
@@ -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} />;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user