Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
setupDomain,
} from "@calcom/features/ee/organizations/lib/server/orgCreationUtils";
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import { WEBAPP_URL } from "@calcom/lib/constants";
Expand Down Expand Up @@ -143,8 +144,53 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
return organizationOnboarding;
}

protected filterTeamsAndInvites(teams: TeamInput[] = [], invitedMembers: InvitedMemberInput[] = []) {
const teamsData = teams
private async ensureConflictingSlugTeamIsMigrated(
orgSlug: string,
teams: TeamInput[] = []
): Promise<TeamInput[]> {
const teamRepository = new TeamRepository(prisma);
const ownedTeams = await teamRepository.findOwnedTeamsByUserId({ userId: this.user.id });

const conflictingTeam = ownedTeams.find((team) => team.slug === orgSlug);

if (!conflictingTeam) {
return teams;
}

const existingTeamIndex = teams.findIndex((t) => t.id === conflictingTeam.id);

if (existingTeamIndex !== -1) {
const existingTeam = teams[existingTeamIndex];
if (!existingTeam.isBeingMigrated) {
const updatedTeams = [...teams];
updatedTeams[existingTeamIndex] = {
...existingTeam,
isBeingMigrated: true,
};
return updatedTeams;
}
return teams;
}

return [
...teams,
{
id: conflictingTeam.id,
name: conflictingTeam.name,
isBeingMigrated: true,
slug: conflictingTeam.slug,
},
];
}

protected async buildTeamsAndInvites(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed filterTeamsAndInvites -> buildTeamsAndInvites

orgSlug: string,
teams: TeamInput[] = [],
invitedMembers: InvitedMemberInput[] = []
) {
const enrichedTeams = await this.ensureConflictingSlugTeamIsMigrated(orgSlug, teams);

const teamsData = enrichedTeams
.filter((team) => team.name.trim().length > 0)
.map((team) => ({
id: team.id === -1 ? -1 : team.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ export class BillingEnabledOrgOnboardingService extends BaseOnboardingService {
})
);

const { teamsData, invitedMembersData } = this.filterTeamsAndInvites(input.teams, input.invitedMembers);
const { teamsData, invitedMembersData } = await this.buildTeamsAndInvites(
input.slug,
input.teams,
input.invitedMembers
);

log.debug(
"BillingEnabledOrgOnboardingService - After filterTeamsAndInvites",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const teamsSchema = orgOnboardingTeamsSchema;
/**
* Handles organization onboarding when billing is disabled (self-hosted admin flow).
*
* Flow:
* Flow:
* 1. Create onboarding record
* 2. Store teams/invites in database
* 3. Immediately create organization, teams, and invite members
Expand All @@ -46,8 +46,12 @@ export class SelfHostedOrganizationOnboardingService extends BaseOnboardingServi
})
);

// Step 1: Filter and normalize teams/invites
const { teamsData, invitedMembersData } = this.filterTeamsAndInvites(input.teams, input.invitedMembers);
// Step 1: Build and validate teams/invites (includes conflict slug detection)
const { teamsData, invitedMembersData } = await this.buildTeamsAndInvites(
input.slug,
input.teams,
input.invitedMembers
);

// Step 2: Create onboarding record with ALL data at once
const organizationOnboarding = await this.createOnboardingRecord({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,181 @@ describe("BillingEnabledOrgOnboardingService", () => {
});
});

it("should automatically migrate team with conflicting slug", async () => {
// Create a team owned by the user with the same slug as the org
const conflictingTeam = await prismock.team.create({
data: {
id: 100,
name: "Test Org Team",
slug: "test-org", // Same as mockInput.slug
},
});

await prismock.membership.create({
data: {
userId: mockUser.id,
teamId: conflictingTeam.id,
role: MembershipRole.OWNER,
accepted: true,
},
});

const inputWithoutConflictingTeam = {
...mockInput,
teams: [{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null }],
};

await service.createOnboardingIntent(inputWithoutConflictingTeam);

// Verify the conflicting team was automatically added to migration
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
expect.objectContaining({
teams: expect.arrayContaining([
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
{ id: 100, name: "Test Org Team", isBeingMigrated: true, slug: "test-org" },
]),
})
);
});

it("should mark existing team for migration if slug conflicts", async () => {
// Create a team owned by the user with the same slug as the org
const conflictingTeam = await prismock.team.create({
data: {
id: 100,
name: "Test Org Team",
slug: "test-org", // Same as mockInput.slug
},
});

await prismock.membership.create({
data: {
userId: mockUser.id,
teamId: conflictingTeam.id,
role: MembershipRole.OWNER,
accepted: true,
},
});

const inputWithConflictingTeamNotMigrated = {
...mockInput,
teams: [
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
{ id: 100, name: "Test Org Team", isBeingMigrated: false, slug: "test-org" },
],
};

await service.createOnboardingIntent(inputWithConflictingTeamNotMigrated);

// Verify the conflicting team was marked for migration
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
expect.objectContaining({
teams: expect.arrayContaining([
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
{ id: 100, name: "Test Org Team", isBeingMigrated: true, slug: "test-org" },
]),
})
);
});

it("should not duplicate team if already marked for migration with conflicting slug", async () => {
// Create a team owned by the user with the same slug as the org
const conflictingTeam = await prismock.team.create({
data: {
id: 100,
name: "Test Org Team",
slug: "test-org", // Same as mockInput.slug
},
});

await prismock.membership.create({
data: {
userId: mockUser.id,
teamId: conflictingTeam.id,
role: MembershipRole.OWNER,
accepted: true,
},
});

const inputWithConflictingTeamAlreadyMigrated = {
...mockInput,
teams: [
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
{ id: 100, name: "Test Org Team", isBeingMigrated: true, slug: "test-org" },
],
};

await service.createOnboardingIntent(inputWithConflictingTeamAlreadyMigrated);

// Verify no duplication occurred
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
expect.objectContaining({
teams: [
{ id: -1, name: "Engineering", isBeingMigrated: false, slug: null },
{ id: 100, name: "Test Org Team", isBeingMigrated: true, slug: "test-org" },
],
})
);
});

it("should not migrate team with non-conflicting slug", async () => {
// Create a team owned by the user with a different slug
const nonConflictingTeam = await prismock.team.create({
data: {
id: 100,
name: "Different Team",
slug: "different-team",
},
});

await prismock.membership.create({
data: {
userId: mockUser.id,
teamId: nonConflictingTeam.id,
role: MembershipRole.OWNER,
accepted: true,
},
});

await service.createOnboardingIntent(mockInput);

// Verify the non-conflicting team was NOT added
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
expect.objectContaining({
teams: mockInput.teams,
})
);
});

it("should only migrate teams where user is OWNER or ADMIN", async () => {
// Create a team with conflicting slug but user is only a MEMBER
const teamAsMember = await prismock.team.create({
data: {
id: 100,
name: "Team As Member",
slug: "test-org",
},
});

await prismock.membership.create({
data: {
userId: mockUser.id,
teamId: teamAsMember.id,
role: MembershipRole.MEMBER,
accepted: true,
},
});

await service.createOnboardingIntent(mockInput);

// Verify the team was NOT migrated (user isn't owner/admin)
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
expect.objectContaining({
teams: mockInput.teams,
})
);
});

it("should immediately create organization when admin creates org for self", async () => {
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);

Expand Down
29 changes: 29 additions & 0 deletions packages/features/ee/teams/repositories/TeamRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,35 @@ export class TeamRepository {
}));
}

/**
* Get teams where the user is an OWNER or ADMIN (excludes organizations)
*/
async findOwnedTeamsByUserId({ userId }: { userId: number }) {
const memberships = await this.prismaClient.membership.findMany({
where: {
userId: userId,
accepted: true,
role: {
in: [MembershipRole.OWNER, MembershipRole.ADMIN],
},
},
include: {
team: {
select: {
id: true,
name: true,
slug: true,
isOrganization: true,
},
},
},
});

return memberships
.filter((mmship) => !mmship.team.isOrganization)
.map((mmship) => mmship.team);
}

async findTeamWithOrganizationSettings(teamId: number) {
return await this.prismaClient.team.findUnique({
where: { id: teamId },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";

import type { TrpcSessionUser } from "../../../types";

Expand All @@ -10,29 +10,6 @@ type ListOptions = {
};

export const listOwnedTeamsHandler = async ({ ctx }: ListOptions) => {
const user = await prisma.user.findUnique({
where: {
id: ctx.user.id,
},
select: {
id: true,
teams: {
where: {
accepted: true,
role: {
in: [MembershipRole.OWNER, MembershipRole.ADMIN],
},
},
select: {
team: true,
},
},
},
});

return user?.teams
?.filter((m) => {
return !m.team.isOrganization;
})
?.map(({ team }) => team);
const teamRepository = new TeamRepository(prisma);
return await teamRepository.findOwnedTeamsByUserId({ userId: ctx.user.id });
};
Loading