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,52 @@ 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 existingTeam = teams.find((t) => t.id === conflictingTeam.id);

if (existingTeam) {
if (existingTeam.isBeingMigrated) {
return teams;
}

return teams.map((team) =>
team.id === conflictingTeam.id
? { ...team, isBeingMigrated: true }
: team
);
}

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
@@ -1,34 +1,45 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";

import type { User } from "@calcom/prisma/client";
import { UserPermissionRole } from "@calcom/prisma/enums";

vi.mock("@calcom/features/ee/teams/repositories/TeamRepository", () => ({
TeamRepository: class {
constructor() {}
findOwnedTeamsByUserId(_: { userId: number }) {
return Promise.resolve([]);
}
},
}));

import { BaseOnboardingService } from "../BaseOnboardingService";
import type { CreateOnboardingIntentInput } from "../types";
import type { CreateOnboardingIntentInput, OnboardingIntentResult } from "../types";

class TestableBaseOnboardingService extends BaseOnboardingService {
async createOnboardingIntent(input: CreateOnboardingIntentInput): Promise<any> {
async createOnboardingIntent(_input: CreateOnboardingIntentInput): Promise<OnboardingIntentResult> {
throw new Error("Not implemented");
}

public testFilterTeamsAndInvites(
public async testBuildTeamsAndInvites(
orgSlug: string,
teams: CreateOnboardingIntentInput["teams"],
invitedMembers: CreateOnboardingIntentInput["invitedMembers"]
) {
return this.filterTeamsAndInvites(teams, invitedMembers);
return this.buildTeamsAndInvites(orgSlug, teams, invitedMembers);
}
}

const mockUser = {
const mockUser: Pick<User, "id" | "email" | "role" | "name"> = {
id: 1,
email: "[email protected]",
role: UserPermissionRole.USER,
name: "Test User",
};

describe("BaseOnboardingService", () => {
describe("filterTeamsAndInvites", () => {
it("should filter out invites with empty emails", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
describe("buildTeamsAndInvites", () => {
it("should filter out invites with empty emails", async () => {
const service = new TestableBaseOnboardingService(mockUser);

const invites = [
{ email: "[email protected]", teamName: "Marketing", role: "MEMBER" },
Expand All @@ -37,7 +48,7 @@ describe("BaseOnboardingService", () => {
{ email: "[email protected]", teamName: "Design", role: "MEMBER" },
];

const { invitedMembersData } = service.testFilterTeamsAndInvites([], invites);
const { invitedMembersData } = await service.testBuildTeamsAndInvites("test-org", [], invites);

expect(invitedMembersData).toHaveLength(2);
expect(invitedMembersData).toEqual([
Expand All @@ -58,8 +69,8 @@ describe("BaseOnboardingService", () => {
]);
});

it("should preserve all fields from invites including role", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
it("should preserve all fields from invites including role", async () => {
const service = new TestableBaseOnboardingService(mockUser);

const invites = [
{
Expand All @@ -78,7 +89,7 @@ describe("BaseOnboardingService", () => {
},
];

const { invitedMembersData } = service.testFilterTeamsAndInvites([], invites);
const { invitedMembersData } = await service.testBuildTeamsAndInvites("test-org", [], invites);

expect(invitedMembersData).toEqual([
{
Expand All @@ -98,15 +109,15 @@ describe("BaseOnboardingService", () => {
]);
});

it("should handle invites without optional fields", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
it("should handle invites without optional fields", async () => {
const service = new TestableBaseOnboardingService(mockUser);

const invites = [
{ email: "[email protected]" },
{ email: "[email protected]", teamName: "Sales" },
];

const { invitedMembersData } = service.testFilterTeamsAndInvites([], invites);
const { invitedMembersData } = await service.testBuildTeamsAndInvites("test-org", [], invites);

expect(invitedMembersData).toEqual([
{
Expand All @@ -126,8 +137,8 @@ describe("BaseOnboardingService", () => {
]);
});

it("should filter out teams with empty names", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
it("should filter out teams with empty names", async () => {
const service = new TestableBaseOnboardingService(mockUser);

const teams = [
{ id: 1, name: "Marketing", isBeingMigrated: false, slug: null },
Expand All @@ -136,7 +147,7 @@ describe("BaseOnboardingService", () => {
{ id: 4, name: "Engineering", isBeingMigrated: true, slug: "eng" },
];

const { teamsData } = service.testFilterTeamsAndInvites(teams, []);
const { teamsData } = await service.testBuildTeamsAndInvites("test-org", teams, []);

expect(teamsData).toHaveLength(2);
expect(teamsData).toEqual([
Expand All @@ -145,42 +156,42 @@ describe("BaseOnboardingService", () => {
]);
});

it("should preserve team properties including migration status", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
it("should preserve team properties including migration status", async () => {
const service = new TestableBaseOnboardingService(mockUser);

const teams = [
{ id: -1, name: "New Team", isBeingMigrated: false, slug: null },
{ id: 42, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" },
];

const { teamsData } = service.testFilterTeamsAndInvites(teams, []);
const { teamsData } = await service.testBuildTeamsAndInvites("test-org", teams, []);

expect(teamsData).toEqual([
{ id: -1, name: "New Team", isBeingMigrated: false, slug: null },
{ id: 42, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" },
]);
});

it("should handle empty teams and invites arrays", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
it("should handle empty teams and invites arrays", async () => {
const service = new TestableBaseOnboardingService(mockUser);

const { teamsData, invitedMembersData } = service.testFilterTeamsAndInvites([], []);
const { teamsData, invitedMembersData } = await service.testBuildTeamsAndInvites("test-org", [], []);

expect(teamsData).toEqual([]);
expect(invitedMembersData).toEqual([]);
});

it("should handle undefined teams and invites", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
it("should handle undefined teams and invites", async () => {
const service = new TestableBaseOnboardingService(mockUser);

const { teamsData, invitedMembersData } = service.testFilterTeamsAndInvites(undefined, undefined);
const { teamsData, invitedMembersData } = await service.testBuildTeamsAndInvites("test-org", undefined, undefined);

expect(teamsData).toEqual([]);
expect(invitedMembersData).toEqual([]);
});

it("should preserve invites with teamId=-1 for new teams", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
it("should preserve invites with teamId=-1 for new teams", async () => {
const service = new TestableBaseOnboardingService(mockUser);

const teams = [
{ id: -1, name: "Marketing", isBeingMigrated: false, slug: null },
Expand All @@ -192,7 +203,7 @@ describe("BaseOnboardingService", () => {
{ email: "[email protected]", teamId: -1, teamName: "Sales", role: "ADMIN" },
];

const { teamsData, invitedMembersData } = service.testFilterTeamsAndInvites(teams, invites);
const { teamsData, invitedMembersData } = await service.testBuildTeamsAndInvites("test-org", teams, invites);

expect(teamsData).toHaveLength(2);
expect(invitedMembersData).toHaveLength(2);
Expand All @@ -202,8 +213,8 @@ describe("BaseOnboardingService", () => {
expect(invitedMembersData[1].role).toBe("ADMIN");
});

it("should handle mixed scenarios with both org-level and team-specific invites", () => {
const service = new TestableBaseOnboardingService(mockUser as any);
it("should handle mixed scenarios with both org-level and team-specific invites", async () => {
const service = new TestableBaseOnboardingService(mockUser);

const teams = [
{ id: -1, name: "Marketing", isBeingMigrated: false, slug: null },
Expand All @@ -216,7 +227,7 @@ describe("BaseOnboardingService", () => {
{ email: "[email protected]", teamName: "Engineering", teamId: 42, role: "ADMIN" },
];

const { teamsData, invitedMembersData } = service.testFilterTeamsAndInvites(teams, invites);
const { teamsData, invitedMembersData } = await service.testBuildTeamsAndInvites("test-org", teams, invites);

expect(teamsData).toHaveLength(2);
expect(invitedMembersData).toHaveLength(3);
Expand Down
Loading
Loading