-
Notifications
You must be signed in to change notification settings - Fork 0
Code Review Bench PR #26829 - fix: add server-side redirect for users with pending invites on onboarding page #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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 } }); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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: true, | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚨 Bug:
|
||||||||||||||||||||||
| accepted: true, | |
| const pendingInvite = await prisma.membership.findFirst({ | |
| where: { | |
| userId, | |
| accepted: false, | |
| }, | |
| select: { | |
| id: true, | |
| }, | |
| }); |
- Apply suggested fix
There was a problem hiding this comment.
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 redirectThe 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 toif (hasPendingInvite || onboardingV3Enabled), which means:onboardingV3Enabledwill be redirected to/onboarding/personal/settingsregardless of invite status, bypassing the getting-started flow entirely.return onboardingV3Enabled ? "/onboarding/getting-started" : "/getting-started"can never evaluateonboardingV3Enabledastruebecause 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 👍 / 👎