Skip to content

Commit e29aa20

Browse files
hariombalharadevin-ai-integration[bot]Udit-takkar
authored
feat: Ensure teams with conflicting slugs owned by the user are migrated(handled in backend, frontend already had this restriction) (#25291)
* feat: add backend validation for conflicting team slugs during org onboarding - Added findOwnedTeamsByUserId method to TeamRepository - Created buildTeamsAndInvites method in BaseOnboardingService that automatically: - Detects teams with same slug as organization - Marks conflicting teams for migration (isBeingMigrated: true) - Filters empty team names and invite emails - Updated BillingEnabledOrgOnboardingService to use new method - Updated SelfHostedOnboardingService to use new method - Added comprehensive tests for slug conflict scenarios This ensures backend validation even if frontend is bypassed, preventing slug conflicts during organization creation. All inheriting classes automatically get this validation without code changes. * refactor: use TeamRepository in listOwnedTeamsHandler Refactored listOwnedTeamsHandler to use TeamRepository.findOwnedTeamsByUserId instead of direct Prisma queries. This: - Reduces code duplication - Ensures consistency across the codebase - Follows repository pattern - Makes the handler more maintainable * fix: update tests to use renamed buildTeamsAndInvites method - Renamed testFilterTeamsAndInvites to testBuildTeamsAndInvites - Made test wrapper method async to match the async buildTeamsAndInvites - Added orgSlug parameter to all test calls - Updated all 9 test cases to use await with the new method signature - Fixed lint warnings by using proper types instead of 'any' - Imported OnboardingIntentResult and User types - Used Pick<User> for mockUser type - Removed all 'as any' type casts Fixes test failures where filterTeamsAndInvites was renamed to buildTeamsAndInvites in the base service. Co-Authored-By: [email protected] <[email protected]> * fix: mock TeamRepository in tests to prevent database calls Added vi.mock for TeamRepository to avoid database calls in unit tests. The buildTeamsAndInvites method now calls ensureConflictingSlugTeamIsMigrated which uses TeamRepository.findOwnedTeamsByUserId(). Mocking this prevents Prisma errors in CI while keeping the tests focused on filtering logic. The mock returns an empty array so no teams are found for migration, allowing the tests to verify the filtering behavior without database access. Co-Authored-By: [email protected] <[email protected]> * refactor: improve readability of ensureConflictingSlugTeamIsMigrated Refactored the conditional logic in ensureConflictingSlugTeamIsMigrated for better readability while preserving exact behavior. Changed from manual array manipulation to using .map() for updating team migration status. This is a cosmetic change with no functional differences. Co-Authored-By: [email protected] <[email protected]> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Udit Takkar <[email protected]>
1 parent 1578dee commit e29aa20

File tree

7 files changed

+311
-66
lines changed

7 files changed

+311
-66
lines changed

packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
setupDomain,
1010
} from "@calcom/features/ee/organizations/lib/server/orgCreationUtils";
1111
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
12+
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
1213
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
1314
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
1415
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -143,8 +144,52 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
143144
return organizationOnboarding;
144145
}
145146

146-
protected filterTeamsAndInvites(teams: TeamInput[] = [], invitedMembers: InvitedMemberInput[] = []) {
147-
const teamsData = teams
147+
private async ensureConflictingSlugTeamIsMigrated(
148+
orgSlug: string,
149+
teams: TeamInput[] = []
150+
): Promise<TeamInput[]> {
151+
const teamRepository = new TeamRepository(prisma);
152+
const ownedTeams = await teamRepository.findOwnedTeamsByUserId({ userId: this.user.id });
153+
154+
const conflictingTeam = ownedTeams.find((team) => team.slug === orgSlug);
155+
156+
if (!conflictingTeam) {
157+
return teams;
158+
}
159+
160+
const existingTeam = teams.find((t) => t.id === conflictingTeam.id);
161+
162+
if (existingTeam) {
163+
if (existingTeam.isBeingMigrated) {
164+
return teams;
165+
}
166+
167+
return teams.map((team) =>
168+
team.id === conflictingTeam.id
169+
? { ...team, isBeingMigrated: true }
170+
: team
171+
);
172+
}
173+
174+
return [
175+
...teams,
176+
{
177+
id: conflictingTeam.id,
178+
name: conflictingTeam.name,
179+
isBeingMigrated: true,
180+
slug: conflictingTeam.slug,
181+
},
182+
];
183+
}
184+
185+
protected async buildTeamsAndInvites(
186+
orgSlug: string,
187+
teams: TeamInput[] = [],
188+
invitedMembers: InvitedMemberInput[] = []
189+
) {
190+
const enrichedTeams = await this.ensureConflictingSlugTeamIsMigrated(orgSlug, teams);
191+
192+
const teamsData = enrichedTeams
148193
.filter((team) => team.name.trim().length > 0)
149194
.map((team) => ({
150195
id: team.id === -1 ? -1 : team.id,

packages/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@ export class BillingEnabledOrgOnboardingService extends BaseOnboardingService {
4343
})
4444
);
4545

46-
const { teamsData, invitedMembersData } = this.filterTeamsAndInvites(input.teams, input.invitedMembers);
46+
const { teamsData, invitedMembersData } = await this.buildTeamsAndInvites(
47+
input.slug,
48+
input.teams,
49+
input.invitedMembers
50+
);
4751

4852
log.debug(
4953
"BillingEnabledOrgOnboardingService - After filterTeamsAndInvites",

packages/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const teamsSchema = orgOnboardingTeamsSchema;
2828
/**
2929
* Handles organization onboarding when billing is disabled (self-hosted admin flow).
3030
*
31-
* Flow:
31+
* Flow:
3232
* 1. Create onboarding record
3333
* 2. Store teams/invites in database
3434
* 3. Immediately create organization, teams, and invite members
@@ -46,8 +46,12 @@ export class SelfHostedOrganizationOnboardingService extends BaseOnboardingServi
4646
})
4747
);
4848

49-
// Step 1: Filter and normalize teams/invites
50-
const { teamsData, invitedMembersData } = this.filterTeamsAndInvites(input.teams, input.invitedMembers);
49+
// Step 1: Build and validate teams/invites (includes conflict slug detection)
50+
const { teamsData, invitedMembersData } = await this.buildTeamsAndInvites(
51+
input.slug,
52+
input.teams,
53+
input.invitedMembers
54+
);
5155

5256
// Step 2: Create onboarding record with ALL data at once
5357
const organizationOnboarding = await this.createOnboardingRecord({

packages/features/ee/organizations/lib/service/onboarding/__tests__/BaseOnboardingService.test.ts

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,45 @@
1-
import { describe, expect, it } from "vitest";
1+
import { describe, expect, it, vi } from "vitest";
22

3+
import type { User } from "@calcom/prisma/client";
34
import { UserPermissionRole } from "@calcom/prisma/enums";
45

6+
vi.mock("@calcom/features/ee/teams/repositories/TeamRepository", () => ({
7+
TeamRepository: class {
8+
constructor() {}
9+
findOwnedTeamsByUserId(_: { userId: number }) {
10+
return Promise.resolve([]);
11+
}
12+
},
13+
}));
14+
515
import { BaseOnboardingService } from "../BaseOnboardingService";
6-
import type { CreateOnboardingIntentInput } from "../types";
16+
import type { CreateOnboardingIntentInput, OnboardingIntentResult } from "../types";
717

818
class TestableBaseOnboardingService extends BaseOnboardingService {
9-
async createOnboardingIntent(input: CreateOnboardingIntentInput): Promise<any> {
19+
async createOnboardingIntent(_input: CreateOnboardingIntentInput): Promise<OnboardingIntentResult> {
1020
throw new Error("Not implemented");
1121
}
1222

13-
public testFilterTeamsAndInvites(
23+
public async testBuildTeamsAndInvites(
24+
orgSlug: string,
1425
teams: CreateOnboardingIntentInput["teams"],
1526
invitedMembers: CreateOnboardingIntentInput["invitedMembers"]
1627
) {
17-
return this.filterTeamsAndInvites(teams, invitedMembers);
28+
return this.buildTeamsAndInvites(orgSlug, teams, invitedMembers);
1829
}
1930
}
2031

21-
const mockUser = {
32+
const mockUser: Pick<User, "id" | "email" | "role" | "name"> = {
2233
id: 1,
2334
2435
role: UserPermissionRole.USER,
2536
name: "Test User",
2637
};
2738

2839
describe("BaseOnboardingService", () => {
29-
describe("filterTeamsAndInvites", () => {
30-
it("should filter out invites with empty emails", () => {
31-
const service = new TestableBaseOnboardingService(mockUser as any);
40+
describe("buildTeamsAndInvites", () => {
41+
it("should filter out invites with empty emails", async () => {
42+
const service = new TestableBaseOnboardingService(mockUser);
3243

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

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

4253
expect(invitedMembersData).toHaveLength(2);
4354
expect(invitedMembersData).toEqual([
@@ -58,8 +69,8 @@ describe("BaseOnboardingService", () => {
5869
]);
5970
});
6071

61-
it("should preserve all fields from invites including role", () => {
62-
const service = new TestableBaseOnboardingService(mockUser as any);
72+
it("should preserve all fields from invites including role", async () => {
73+
const service = new TestableBaseOnboardingService(mockUser);
6374

6475
const invites = [
6576
{
@@ -78,7 +89,7 @@ describe("BaseOnboardingService", () => {
7889
},
7990
];
8091

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

8394
expect(invitedMembersData).toEqual([
8495
{
@@ -98,15 +109,15 @@ describe("BaseOnboardingService", () => {
98109
]);
99110
});
100111

101-
it("should handle invites without optional fields", () => {
102-
const service = new TestableBaseOnboardingService(mockUser as any);
112+
it("should handle invites without optional fields", async () => {
113+
const service = new TestableBaseOnboardingService(mockUser);
103114

104115
const invites = [
105116
{ email: "[email protected]" },
106117
{ email: "[email protected]", teamName: "Sales" },
107118
];
108119

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

111122
expect(invitedMembersData).toEqual([
112123
{
@@ -126,8 +137,8 @@ describe("BaseOnboardingService", () => {
126137
]);
127138
});
128139

129-
it("should filter out teams with empty names", () => {
130-
const service = new TestableBaseOnboardingService(mockUser as any);
140+
it("should filter out teams with empty names", async () => {
141+
const service = new TestableBaseOnboardingService(mockUser);
131142

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

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

141152
expect(teamsData).toHaveLength(2);
142153
expect(teamsData).toEqual([
@@ -145,42 +156,42 @@ describe("BaseOnboardingService", () => {
145156
]);
146157
});
147158

148-
it("should preserve team properties including migration status", () => {
149-
const service = new TestableBaseOnboardingService(mockUser as any);
159+
it("should preserve team properties including migration status", async () => {
160+
const service = new TestableBaseOnboardingService(mockUser);
150161

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

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

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

164-
it("should handle empty teams and invites arrays", () => {
165-
const service = new TestableBaseOnboardingService(mockUser as any);
175+
it("should handle empty teams and invites arrays", async () => {
176+
const service = new TestableBaseOnboardingService(mockUser);
166177

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

169180
expect(teamsData).toEqual([]);
170181
expect(invitedMembersData).toEqual([]);
171182
});
172183

173-
it("should handle undefined teams and invites", () => {
174-
const service = new TestableBaseOnboardingService(mockUser as any);
184+
it("should handle undefined teams and invites", async () => {
185+
const service = new TestableBaseOnboardingService(mockUser);
175186

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

178189
expect(teamsData).toEqual([]);
179190
expect(invitedMembersData).toEqual([]);
180191
});
181192

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

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

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

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

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

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

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

221232
expect(teamsData).toHaveLength(2);
222233
expect(invitedMembersData).toHaveLength(3);

0 commit comments

Comments
 (0)