Skip to content

Commit ff264d6

Browse files
anikdhabalUdit-takkaremrysal
authored
fix: allow team with same slug for diff cases (calcom#24029)
* fix: aalow team with slug for diff cases * addressed review * fix type error * update test * addressed review * fix test * Update team.ts --------- Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Alex van Andel <me@alexvanandel.com>
1 parent 8019c41 commit ff264d6

6 files changed

Lines changed: 201 additions & 27 deletions

File tree

apps/api/v1/pages/api/teams/[teamId]/_patch.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/pay
44
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
55
import { HttpError } from "@calcom/lib/http-error";
66
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
7+
import { TeamRepository } from "@calcom/lib/server/repository/team";
78
import prisma from "@calcom/prisma";
89
import type { Prisma } from "@calcom/prisma/client";
910

@@ -61,24 +62,24 @@ export async function patchHandler(req: NextApiRequest) {
6162
const { teamId } = schemaQueryTeamId.parse(req.query);
6263

6364
/** Only OWNERS and ADMINS can edit teams */
64-
const _team = await prisma.team.findFirst({
65+
const team = await prisma.team.findFirst({
6566
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
6667
include: { members: true },
6768
where: { id: teamId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
6869
});
69-
if (!_team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" });
70+
if (!team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" });
7071

71-
const slugAlreadyExists = await prisma.team.findFirst({
72-
where: {
73-
slug: {
74-
mode: "insensitive",
75-
equals: data.slug,
76-
},
77-
},
78-
});
79-
80-
if (slugAlreadyExists && data.slug !== _team.slug)
81-
throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
72+
if (data.slug) {
73+
const teamRepository = new TeamRepository(prisma);
74+
const isSlugAvailable = await teamRepository.isSlugAvailableForUpdate({
75+
slug: data.slug,
76+
teamId: team.id,
77+
parentId: team.parentId,
78+
});
79+
if (!isSlugAvailable) {
80+
throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
81+
}
82+
}
8283

8384
// Check if parentId is related to this user
8485
if (data.parentId && data.parentId === teamId) {
@@ -99,16 +100,16 @@ export async function patchHandler(req: NextApiRequest) {
99100
}
100101

101102
let paymentUrl;
102-
if (_team.slug === null && data.slug) {
103+
if (team.slug === null && data.slug) {
103104
data.metadata = {
104-
...(_team.metadata as Prisma.JsonObject),
105+
...(team.metadata as Prisma.JsonObject),
105106
requestedSlug: data.slug,
106107
};
107108
delete data.slug;
108109
if (IS_TEAM_BILLING_ENABLED) {
109110
const checkoutSession = await purchaseTeamOrOrgSubscription({
110-
teamId: _team.id,
111-
seatsUsed: _team.members.length,
111+
teamId: team.id,
112+
seatsUsed: team.members.length,
112113
userId,
113114
pricePerSeat: null,
114115
});
@@ -131,9 +132,9 @@ export async function patchHandler(req: NextApiRequest) {
131132
bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits,
132133
metadata: data.metadata === null ? {} : data.metadata || undefined,
133134
};
134-
const team = await prisma.team.update({ where: { id: teamId }, data: cloneData });
135+
const updatedTeam = await prisma.team.update({ where: { id: teamId }, data: cloneData });
135136
const result = {
136-
team: schemaTeamReadPublic.parse(team),
137+
team: schemaTeamReadPublic.parse(updatedTeam),
137138
paymentUrl,
138139
};
139140
if (!paymentUrl) {

apps/web/playwright/fixtures/users.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ const createTeamAndAddUser = async (
160160
orgRequestedSlug,
161161
schedulingType,
162162
assignAllTeamMembersForSubTeamEvents,
163+
teamSlug,
163164
}: {
164165
user: { id: number; email: string; username: string | null; role?: MembershipRole };
165166
isUnpublished?: boolean;
@@ -172,12 +173,15 @@ const createTeamAndAddUser = async (
172173
orgRequestedSlug?: string;
173174
schedulingType?: SchedulingType;
174175
assignAllTeamMembersForSubTeamEvents?: boolean;
176+
teamSlug?: string;
175177
},
176178
workerInfo: WorkerInfo
177179
) => {
178180
const slugIndex = index ? `-count-${index}` : "";
179181
const slug =
180-
orgRequestedSlug ?? `${isOrg ? "org" : "team"}-${workerInfo.workerIndex}-${Date.now()}${slugIndex}`;
182+
teamSlug ??
183+
orgRequestedSlug ??
184+
`${isOrg ? "org" : "team"}-${workerInfo.workerIndex}-${Date.now()}${slugIndex}`;
181185
const data: PrismaType.TeamCreateInput = {
182186
name: `user-id-${user.id}'s ${isOrg ? "Org" : "Team"}`,
183187
isOrganization: isOrg,
@@ -283,6 +287,7 @@ export const createUsersFixture = (
283287
schedulingType?: SchedulingType;
284288
teamEventTitle?: string;
285289
teamEventSlug?: string;
290+
teamSlug?: string;
286291
teamEventLength?: number;
287292
isOrg?: boolean;
288293
isOrgVerified?: boolean;
@@ -367,6 +372,7 @@ export const createUsersFixture = (
367372
orgRequestedSlug: scenario.orgRequestedSlug,
368373
schedulingType: scenario.schedulingType,
369374
assignAllTeamMembersForSubTeamEvents: scenario.assignAllTeamMembersForSubTeamEvents,
375+
teamSlug: scenario?.teamSlug,
370376
},
371377
workerInfo
372378
);
@@ -465,7 +471,7 @@ export const createUsersFixture = (
465471
},
466472
data: {
467473
orgProfiles: _user.profiles.length
468-
? {
474+
? {
469475
connect: _user.profiles.map((profile) => ({ id: profile.id })),
470476
}
471477
: {
@@ -711,6 +717,31 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
711717
}
712718
return membership;
713719
},
720+
getAllTeamMembership: async () => {
721+
const memberships = await prisma.membership.findMany({
722+
where: {
723+
userId: user.id,
724+
team: {
725+
isOrganization: false,
726+
},
727+
},
728+
include: { team: true, user: true },
729+
});
730+
731+
const filteredMemberships = memberships.map((membership) => ({
732+
...membership,
733+
team: {
734+
...membership.team,
735+
metadata: teamMetadataSchema.parse(membership.team.metadata),
736+
},
737+
}));
738+
739+
if (filteredMemberships.length === 0) {
740+
throw new Error("No team memberships found for user");
741+
}
742+
743+
return filteredMemberships;
744+
},
714745
getOrgMembership: async () => {
715746
const membership = await prisma.membership.findFirstOrThrow({
716747
where: {

apps/web/playwright/teams.e2e.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
confirmReschedule,
1111
fillStripeTestCheckout,
1212
selectFirstAvailableTimeSlotNextMonth,
13+
submitAndWaitForResponse,
1314
testName,
1415
} from "./lib/testUtils";
1516

@@ -301,3 +302,108 @@ test.describe("Teams - NonOrg", () => {
301302
});
302303
todo("Reschedule a Round Robin EventType booking");
303304
});
305+
306+
test.describe("Team Slug Validation", () => {
307+
test.afterEach(({ users, orgs }) => {
308+
users.deleteAll();
309+
orgs.deleteAll();
310+
});
311+
312+
test("Teams in different organizations can have the same slug", async ({ page, users, orgs }) => {
313+
const org1 = await orgs.create({ name: "Organization 1" });
314+
const org2 = await orgs.create({ name: "Organization 2" });
315+
316+
const owner1 = await users.create(
317+
{
318+
organizationId: org1.id,
319+
roleInOrganization: "OWNER",
320+
},
321+
{
322+
hasTeam: true,
323+
teamSlug: "cal",
324+
teamRole: "OWNER",
325+
}
326+
);
327+
328+
const owner2 = await users.create(
329+
{
330+
organizationId: org2.id,
331+
roleInOrganization: "OWNER",
332+
},
333+
{
334+
hasTeam: true,
335+
teamSlug: "calCom",
336+
teamRole: "OWNER",
337+
}
338+
);
339+
const { team: team1 } = await owner1.getFirstTeamMembership();
340+
341+
await owner1.apiLogin();
342+
await page.goto(`/settings/teams/${team1.id}/profile`);
343+
await page.locator('input[name="slug"]').fill("calCom");
344+
await submitAndWaitForResponse(page, "/api/trpc/teams/update?batch=1", {
345+
action: () => page.locator("[data-testid=update-team-profile]").click(),
346+
});
347+
});
348+
349+
test("Teams within same organization cannot have duplicate slugs", async ({ page, users, orgs }) => {
350+
const org = await orgs.create({ name: "Organization 1" });
351+
352+
const owner = await users.create(
353+
{
354+
organizationId: org.id,
355+
roleInOrganization: "OWNER",
356+
},
357+
{
358+
hasTeam: true,
359+
numberOfTeams: 2,
360+
teamRole: "OWNER",
361+
}
362+
);
363+
364+
const teams = await owner.getAllTeamMembership();
365+
await owner.apiLogin();
366+
await page.goto(`/settings/teams/${teams[0].team.id}/profile`);
367+
if (!teams[1].team.slug) throw new Error("Slug not found for team 2");
368+
await page.locator('input[name="slug"]').fill(teams[1].team.slug);
369+
await submitAndWaitForResponse(page, "/api/trpc/teams/update?batch=1", {
370+
action: () => page.locator("[data-testid=update-team-profile]").click(),
371+
expectedStatusCode: 409,
372+
});
373+
});
374+
375+
test("Teams without organization can have same slug as teams in organizations", async ({
376+
page,
377+
users,
378+
orgs,
379+
}) => {
380+
const org = await orgs.create({ name: "Organization 1" });
381+
382+
const orgOwner = await users.create(
383+
{
384+
organizationId: org.id,
385+
roleInOrganization: "OWNER",
386+
},
387+
{
388+
hasTeam: true,
389+
teamSlug: "calCom",
390+
teamRole: "OWNER",
391+
}
392+
);
393+
394+
const teamOwner = await users.create(
395+
{ username: "pro-user", name: "pro-user" },
396+
{
397+
hasTeam: true,
398+
}
399+
);
400+
401+
const { team } = await teamOwner.getFirstTeamMembership();
402+
await teamOwner.apiLogin();
403+
await page.goto(`/settings/teams/${team.id}/profile`);
404+
await page.locator('input[name="slug"]').fill("calCom");
405+
await submitAndWaitForResponse(page, "/api/trpc/teams/update?batch=1", {
406+
action: () => page.locator("[data-testid=update-team-profile]").click(),
407+
});
408+
});
409+
});

packages/features/ee/teams/pages/team-profile-view.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,12 @@ const TeamProfileForm = ({ team, teamId }: TeamProfileFormProps) => {
466466
<p className="text-default mt-2 text-sm">{t("team_description")}</p>
467467
</div>
468468
<SectionBottomActions align="end">
469-
<Button color="primary" type="submit" loading={mutation.isPending} disabled={isDisabled}>
469+
<Button
470+
color="primary"
471+
type="submit"
472+
loading={mutation.isPending}
473+
disabled={isDisabled}
474+
data-testid="update-team-profile">
470475
{t("update")}
471476
</Button>
472477
{IS_TEAM_BILLING_ENABLED &&

packages/lib/server/repository/team.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,4 +411,30 @@ export class TeamRepository {
411411
},
412412
});
413413
}
414+
415+
async isSlugAvailableForUpdate({
416+
slug,
417+
teamId,
418+
parentId,
419+
}: {
420+
slug: string;
421+
teamId: number;
422+
parentId?: number | null;
423+
}) {
424+
const whereClause: Prisma.TeamWhereInput = {
425+
slug: {
426+
equals: slug,
427+
mode: "insensitive",
428+
},
429+
parentId: parentId ?? null,
430+
NOT: { id: teamId },
431+
};
432+
433+
const conflictingTeam = await this.prismaClient.team.findFirst({
434+
where: whereClause,
435+
select: { id: true },
436+
});
437+
438+
return !conflictingTeam;
439+
}
414440
}

packages/trpc/server/routers/viewer/teams/update.handler.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
44
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
55
import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
66
import { uploadLogo } from "@calcom/lib/server/avatar";
7+
import { TeamRepository } from "@calcom/lib/server/repository/team";
78
import { prisma } from "@calcom/prisma";
89
import type { Prisma } from "@calcom/prisma/client";
910
import { MembershipRole, RedirectType, RRTimestampBasis } from "@calcom/prisma/enums";
@@ -39,12 +40,16 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
3940
}
4041

4142
if (input.slug) {
42-
const userConflict = await prisma.team.findMany({
43-
where: {
44-
slug: input.slug,
45-
},
43+
const orgId = ctx.user.organizationId;
44+
const teamRepository = new TeamRepository(prisma);
45+
const isSlugAvailable = await teamRepository.isSlugAvailableForUpdate({
46+
slug: input.slug,
47+
teamId: input.id,
48+
parentId: orgId,
4649
});
47-
if (userConflict.some((t) => t.id !== input.id)) return;
50+
if (!isSlugAvailable) {
51+
throw new TRPCError({ code: "CONFLICT", message: "Slug already in use." });
52+
}
4853
}
4954

5055
const prevTeam = await prisma.team.findUnique({

0 commit comments

Comments
 (0)