fix: Issues observed when inviting members to organization either through teams migration during onboarding or explicit user invite (#22901)

* fix: make team migrations sequential to avoid race conditions

Instead of processing team migrations in parallel using Promise.all,
process them sequentially to avoid race conditions when creating
profiles and memberships for team members.

This simplifies the implementation and avoids the need for complex
retry logic or database-level concurrency controls.

* Promise.all accepts an array of promises and not an array with array in it as the first item. This fixes the issue where these promises were unhandled

* Fix the issue where an existing pending membership with team stays pending even though theorganization membership gets created as accepted
This commit is contained in:
Hariom Balhara authored and GitHub committed 2025-08-13 10:48:17 +00:00
1 parent 94a04f0dc2
commit b7fda31678
5 files changed
+533 -22

No files matched your search

@@ -95,21 +95,18 @@ export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) =>
teamNames.map((item) => slugify(item)).includes(slug)
);
await Promise.all(
moveTeams
.filter((team) => team.shouldMove)
.map(async ({ id: teamId, newSlug }) => {
await moveTeam({
teamId,
newSlug,
org: {
...organization,
ownerId: organizationOwner.id,
},
creationSource,
});
})
);
// Process team migrations sequentially to avoid race conditions - Moving a team invites members to the organization again and there are known unique constraints failure in membership and profile creation if done in parallel and a user happens to be part of more than one team
for (const team of moveTeams.filter((team) => team.shouldMove)) {
await moveTeam({
teamId: team.id,
newSlug: team.newSlug,
org: {
...organization,
ownerId: organizationOwner.id,
},
creationSource,
});
}
if (duplicatedSlugs.length === teamNames.length) {
return { duplicatedSlugs };
@@ -0,0 +1,438 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { prisma } from "@calcom/prisma";
import type { Team, User, Membership, Profile } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import inviteMemberHandler, { inviteMembersWithNoInviterPermissionCheck } from "./inviteMember.handler";
// Helper functions for database verification
async function verifyProfileExists(userId: number, organizationId: number): Promise<Profile | null> {
return await prisma.profile.findUnique({
where: {
userId_organizationId: {
userId,
organizationId,
},
},
});
}
async function verifyMembershipExists(userId: number, teamId: number): Promise<Membership | null> {
return await prisma.membership.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
}
async function verifyUserOrganizationConsistency(userId: number): Promise<{
profileCount: number;
acceptedMembershipCount: number;
pendingMembershipCount: number;
}> {
const profiles = await prisma.profile.count({ where: { userId } });
const acceptedMemberships = await prisma.membership.count({
where: { userId, accepted: true },
});
const pendingMemberships = await prisma.membership.count({
where: { userId, accepted: false },
});
return {
profileCount: profiles,
acceptedMembershipCount: acceptedMemberships,
pendingMembershipCount: pendingMemberships,
};
}
// Test data creation helpers with unique identifiers
function generateUniqueId() {
return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
}
async function createTestUser(data: { email: string; username: string; name?: string }): Promise<User> {
const uniqueId = generateUniqueId();
const uniqueEmail = data.email.includes("@")
? data.email.replace("@", `-${uniqueId}@`)
: `${data.email}-${uniqueId}@example.com`;
const uniqueUsername = `${data.username}-${uniqueId}`;
// Pre-emptive cleanup to avoid conflicts
try {
await prisma.user.deleteMany({
where: { OR: [{ email: uniqueEmail }, { username: uniqueUsername }] },
});
} catch {}
return await prisma.user.create({
data: {
email: uniqueEmail,
username: uniqueUsername,
name: data.name || uniqueUsername,
},
});
}
async function createTestTeam(data: {
name: string;
slug: string;
isOrganization?: boolean;
parentId?: number;
metadata?: any;
organizationSettings?: {
orgAutoAcceptEmail?: string;
isOrganizationVerified?: boolean;
};
}): Promise<Team> {
const uniqueId = generateUniqueId();
const uniqueSlug = `${data.slug}-${uniqueId}`;
// Pre-emptive cleanup to avoid conflicts
try {
await prisma.team.deleteMany({
where: { slug: uniqueSlug },
});
} catch {
// Ignore cleanup errors
}
const team = await prisma.team.create({
data: {
name: `${data.name} ${uniqueId}`,
slug: uniqueSlug,
isOrganization: data.isOrganization || false,
parentId: data.parentId,
metadata: data.metadata,
},
});
// If this is an organization and organizationSettings are provided, create them
if (data.isOrganization && data.organizationSettings) {
await prisma.organizationSettings.create({
data: {
organizationId: team.id,
orgAutoAcceptEmail: data.organizationSettings.orgAutoAcceptEmail,
isOrganizationVerified: data.organizationSettings.isOrganizationVerified,
},
});
}
return team;
}
// Mock translations fetch
vi.mock("node-fetch", () => ({
default: vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
})
),
}));
// Mock email sending
vi.mock("@calcom/emails", () => ({
sendTeamInviteEmail: vi.fn(() => Promise.resolve()),
sendOrganizationAutoJoinEmail: vi.fn(() => Promise.resolve()),
}));
// Mock for getTranslation
vi.mock("@calcom/lib/server/i18n", () => ({
getTranslation: vi.fn(() => Promise.resolve((key: string) => key)),
}));
describe("inviteMember.handler Integration Tests", () => {
// Track created test data for cleanup
let testUsers: User[] = [];
let testTeams: Team[] = [];
beforeEach(async () => {
// Reset tracking arrays
testUsers = [];
testTeams = [];
});
afterEach(async () => {
// Clean up test data in reverse dependency order
const userIds = testUsers.map((u) => u.id);
const teamIds = testTeams.map((t) => t.id);
if (userIds.length > 0 || teamIds.length > 0) {
await prisma.$transaction([
// Clean verification tokens for test users
prisma.verificationToken.deleteMany({
where: { identifier: { in: testUsers.map((u) => u.email) } },
}),
// Clean memberships
prisma.membership.deleteMany({
where: {
OR: [{ userId: { in: userIds } }, { teamId: { in: teamIds } }],
},
}),
// Clean profiles
prisma.profile.deleteMany({
where: {
OR: [{ userId: { in: userIds } }, { organizationId: { in: teamIds } }],
},
}),
// Clean teams
prisma.team.deleteMany({
where: { id: { in: teamIds } },
}),
// Clean users
prisma.user.deleteMany({
where: { id: { in: userIds } },
}),
]);
}
});
// Helper to track created entities
function trackUser(user: User): User {
testUsers.push(user);
return user;
}
function trackTeam(team: Team): Team {
testTeams.push(team);
return team;
}
// Helper to create a proper user context for inviteMemberHandler
function createUserContext(user: User, organizationId: number | null = null): NonNullable<TrpcSessionUser> {
const baseUser = {
id: user.id,
email: user.email,
username: user.username || "",
name: user.name,
emailVerified: user.emailVerified,
avatarUrl: user.avatarUrl,
avatar: `https://example.com/avatar.png`,
locale: user.locale || "en",
timeZone: user.timeZone || "UTC",
role: user.role,
allowDynamicBooking: user.allowDynamicBooking || true,
completedOnboarding: user.completedOnboarding,
twoFactorEnabled: user.twoFactorEnabled,
identityProvider: user.identityProvider,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
theme: user.theme,
createdDate: user.createdDate,
disableImpersonation: user.disableImpersonation,
movedToProfileId: user.movedToProfileId,
organizationId,
organization: organizationId
? {
id: organizationId,
isOrgAdmin: true,
metadata: {},
requestedSlug: null,
}
: {
id: null,
isOrgAdmin: false,
metadata: {},
requestedSlug: null,
},
defaultBookerLayouts: null,
profile: organizationId
? {
id: user.id,
upId: `${user.username}-${organizationId}`,
username: user.username || "",
userId: user.id,
organizationId,
organization: {
id: organizationId,
name: "Test Organization",
slug: "test-org",
isOrganization: true,
metadata: {},
},
}
: {
id: user.id,
upId: `${user.username}-default`,
username: user.username || "",
userId: user.id,
organizationId: null,
organization: null,
},
};
return baseUser as unknown as NonNullable<TrpcSessionUser>;
}
describe("Organization Direct Invite Flow", () => {
it("should not auto-accept user's membership that was unaccepted when migrating to org with non-matching autoAcceptEmailDomain", async () => {
const organization = trackTeam(
await createTestTeam({
name: "Test Organization",
slug: "test-org",
isOrganization: true,
metadata: {},
organizationSettings: {
orgAutoAcceptEmail: "company.com",
isOrganizationVerified: true,
},
})
);
const team = trackTeam(
await createTestTeam({
name: "Test Team",
slug: "test-team",
isOrganization: false,
parentId: organization.id,
})
);
const inviterUser = trackUser(
await createTestUser({
email: "inviter@company.com",
username: "inviter",
})
);
await prisma.membership.create({
data: {
userId: inviterUser.id,
teamId: organization.id,
role: MembershipRole.OWNER,
accepted: true,
},
});
// Act: Invite user with non-matching domain
const nonMatchingUser = trackUser(
await createTestUser({
email: "external@other.com",
username: "external",
})
);
await prisma.membership.create({
data: {
userId: nonMatchingUser.id,
teamId: team.id,
role: MembershipRole.MEMBER,
accepted: false,
},
});
await inviteMemberHandler({
ctx: {
user: createUserContext(inviterUser, organization.id),
session: {} as any,
} as any,
input: {
teamId: organization.id,
usernameOrEmail: nonMatchingUser.email,
role: MembershipRole.MEMBER,
language: "en",
creationSource: "WEBAPP" as const,
},
});
// Assert: No profile should be created
const profile = await verifyProfileExists(nonMatchingUser.id, organization.id);
expect(profile).toBeFalsy();
// Membership should not be auto-accepted
const membership = await verifyMembershipExists(nonMatchingUser.id, organization.id);
expect(membership).toBeTruthy();
expect(membership?.accepted).toBe(false);
// Verify that the user is not a member of the organization
const nonMatchingUserMembershipWithTeam = await verifyMembershipExists(nonMatchingUser.id, team.id);
expect(nonMatchingUserMembershipWithTeam).toBeTruthy();
expect(nonMatchingUserMembershipWithTeam?.accepted).toBe(false);
});
it("should auto-accept user's membership that was unaccepted when migrating to org with matching autoAcceptEmailDomain", async () => {
// Setup: User with unverified email who has an unaccepted team membership
const unverifiedUserWithUnacceptedMembership = trackUser(
await createTestUser({
email: "john.doe@acme.com",
username: "john.doe",
})
);
// Add unverified email status - will need to update in DB
await prisma.user.update({
where: { id: unverifiedUserWithUnacceptedMembership.id },
data: { emailVerified: null },
});
const organization = trackTeam(
await createTestTeam({
name: "Acme",
slug: "acme",
isOrganization: true,
metadata: {},
organizationSettings: {
orgAutoAcceptEmail: "acme.com", // Matches user's email domain
isOrganizationVerified: true,
},
})
);
// Create a regular team first where user has unaccepted membership
const regularTeam = trackTeam(
await createTestTeam({
name: "Regular Team",
slug: "regular-team",
isOrganization: false,
parentId: organization.id,
})
);
// Add unaccepted team membership
await prisma.membership.create({
data: {
teamId: regularTeam.id,
userId: unverifiedUserWithUnacceptedMembership.id,
accepted: false, // KEY: Unaccepted membership
role: MembershipRole.MEMBER,
},
});
// Act: Simulate migration by inviting the user to the organization
await inviteMembersWithNoInviterPermissionCheck({
inviterName: null,
teamId: organization.id,
language: "en",
creationSource: "WEBAPP" as const,
orgSlug: organization.slug,
invitations: [
{
usernameOrEmail: unverifiedUserWithUnacceptedMembership.email,
role: MembershipRole.MEMBER,
},
],
});
const orgMembership = await verifyMembershipExists(
unverifiedUserWithUnacceptedMembership.id,
organization.id
);
const profile = await verifyProfileExists(unverifiedUserWithUnacceptedMembership.id, organization.id);
expect(profile?.userId).toBe(unverifiedUserWithUnacceptedMembership.id);
expect(profile?.organizationId).toBe(organization.id);
// Verify original team membership is accepted now
const originalMembership = await verifyMembershipExists(
unverifiedUserWithUnacceptedMembership.id,
regularTeam.id
);
expect(originalMembership?.accepted).toBe(true);
});
});
});
@@ -1,3 +1,8 @@
/**
* This file is deprecated in favour of inviteMember.handler.integration-test.ts
*
* It mocks a lot of things that are untested and integration tests make more sense for this handler
*/
import { scenarios as checkRateLimitAndThrowErrorScenarios } from "../../../../../../../tests/libs/__mocks__/checkRateLimitAndThrowError";
import { mock as getTranslationMock } from "../../../../../../../tests/libs/__mocks__/getTranslation";
import {
@@ -409,7 +414,7 @@ describe("inviteMemberHandler", () => {
// Call inviteMembersWithNoInviterPermissionCheck directly with isDirectUserAction=false
const { inviteMembersWithNoInviterPermissionCheck } = await import("./inviteMember.handler");
const result = await inviteMembersWithNoInviterPermissionCheck({
inviterName: loggedInUser.name,
teamId: team.id,
@@ -487,7 +492,7 @@ describe("inviteMemberHandler", () => {
// Call inviteMembersWithNoInviterPermissionCheck directly with isDirectUserAction=false
const { inviteMembersWithNoInviterPermissionCheck } = await import("./inviteMember.handler");
const result = await inviteMembersWithNoInviterPermissionCheck({
inviterName: "Test Inviter",
teamId: team.id,
@@ -517,6 +522,7 @@ describe("inviteMemberHandler", () => {
);
});
});
it("When rate limit exceeded, it should throw error", async () => {
const userToBeInvited = buildExistingUser({
id: 1,
@@ -39,6 +39,9 @@ const mockedReturnSuccessCheckPerms = {
role: MembershipRole.ADMIN,
userId: 1,
teamId: 1,
createdAt: null,
updatedAt: null,
customRoleId: null,
team: {
id: 1,
name: "Team A",
@@ -95,6 +98,11 @@ const mockedRegularTeam: TeamWithParent = {
smsLockState: "LOCKED",
createdByOAuthClientId: null,
smsLockReviewedByAdmin: false,
hideTeamProfileLink: false,
rrResetInterval: null,
rrTimestampBasis: "CREATED_AT",
bookingLimits: null,
includeManagedEventsInLimits: false,
};
const mockedSubTeam = {
@@ -263,9 +271,13 @@ describe("Invite Member Utils", () => {
adminGetsNoSlotsNotification: false,
isAdminReviewed: false,
isAdminAPIEnabled: false,
allowSEOIndexing: false,
orgProfileRedirectsToVerifiedDomain: false,
disablePhoneOnlySMSNotifications: false,
},
slug: "abc",
parent: null,
isOrganization: true,
};
const result = getOrgState(true, { ...mockedRegularTeam, ...team });
expect(result).toEqual({
@@ -293,6 +305,9 @@ describe("Invite Member Utils", () => {
adminGetsNoSlotsNotification: false,
isAdminReviewed: false,
isAdminAPIEnabled: false,
allowSEOIndexing: false,
orgProfileRedirectsToVerifiedDomain: false,
disablePhoneOnlySMSNotifications: false,
},
},
};
@@ -410,6 +425,7 @@ describe("Invite Member Utils", () => {
...mockedRegularTeam,
parentId: null,
id: inviteeOrgId,
isOrganization: true,
};
expect(canBeInvited(inviteeWithOrg, organization)).toBe(INVITE_STATUS.USER_ALREADY_INVITED_OR_MEMBER);
});
@@ -445,12 +461,33 @@ describe("Invite Member Utils", () => {
const organization = {
...mockedRegularTeam,
id: organizationIdBeingInvitedTo,
isOrganization: true,
};
expect(canBeInvited(inviteeWithOrg, organization)).toBe(
INVITE_STATUS.USER_MEMBER_OF_OTHER_ORGANIZATION
);
});
it("should return CAN_BE_INVITED if the user being invited has a profile with the organization already", () => {
const organizationId = 3;
const inviteeWithOrg: UserWithMembership = {
...invitee,
profiles: [
getSampleProfile({
organizationId: organizationId,
}),
],
teams: [],
};
const organization = {
...mockedRegularTeam,
id: organizationId,
isOrganization: true,
};
expect(canBeInvited(inviteeWithOrg, organization)).toBe(INVITE_STATUS.CAN_BE_INVITED);
});
it("should return USER_MEMBER_OF_OTHER_ORGANIZATION if the invitee is being invited to a sub-team in an organization but he belongs to another organization", () => {
const inviteeOrganizationId = 2;
const subTeamOrganizationId = 3;
@@ -160,10 +160,27 @@ export function canBeInvited(invitee: UserWithMembership, team: TeamWithParent)
return INVITE_STATUS.USER_PENDING_MEMBER_OF_THE_ORG;
}
const hasDifferentOrganizationProfile = invitee.profiles.some((profile) => {
const isRegularTeam = !team.isOrganization && !team.parentId;
if (isRegularTeam) {
// ⚠️ Inviting to a regular team but the user has a profile with some organization
return true;
}
const isOrganization = team.isOrganization && !team.parentId;
if (isOrganization) {
// ⚠️ User has profile with different organization than the organization being invited to
return profile.organizationId !== team.id;
}
// ⚠️ User having profile with an organization is invited to join a sub-team that is not part of the organization
return profile.organizationId != team.parentId;
});
if (
!ENABLE_PROFILE_SWITCHER &&
// Member of an organization is invited to join a team that is not a subteam of the organization
invitee.profiles.find((profile) => profile.organizationId != team.parentId)
// User having profile with an organization is invited to join a sub-team that is not part of the organization
hasDifferentOrganizationProfile
) {
return INVITE_STATUS.USER_MEMBER_OF_OTHER_ORGANIZATION;
}
@@ -828,7 +845,7 @@ export async function handleExistingUsersInvites({
if (parentOrganization) {
const parsedOrg = getParsedTeam(parentOrganization);
// Create profiles if needed
await Promise.all([
await Promise.all(
autoJoinUsers
.concat(regularUsers)
.filter((u) => u.needToCreateProfile)
@@ -841,8 +858,8 @@ export async function handleExistingUsersInvites({
},
organizationId: parsedOrg.id,
})
),
]);
)
);
}
} else {
const organization = team;
@@ -877,6 +894,22 @@ export async function handleExistingUsersInvites({
role: user.newRole,
},
});
// If auto-accepting into org, also accept any pending sub-team memberships
if (shouldAutoAccept) {
await prisma.membership.updateMany({
where: {
userId: user.id,
accepted: false,
team: {
parentId: organization.id,
},
},
data: {
accepted: true,
},
});
}
return {
...user,
profile,