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,
},
select: {
id: true,
},
});
return !!pendingInvite;
}
}