From ff264d6f7a8b8e54a9ed06712caaa0afce4d041e Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Wed, 1 Oct 2025 19:34:20 +0530 Subject: [PATCH] fix: allow team with same slug for diff cases (#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 --- .../api/v1/pages/api/teams/[teamId]/_patch.ts | 39 +++---- apps/web/playwright/fixtures/users.ts | 35 +++++- apps/web/playwright/teams.e2e.ts | 106 ++++++++++++++++++ .../ee/teams/pages/team-profile-view.tsx | 7 +- packages/lib/server/repository/team.ts | 26 +++++ .../routers/viewer/teams/update.handler.ts | 15 ++- 6 files changed, 201 insertions(+), 27 deletions(-) diff --git a/apps/api/v1/pages/api/teams/[teamId]/_patch.ts b/apps/api/v1/pages/api/teams/[teamId]/_patch.ts index a79a4ce09c..d7fe9e33e2 100644 --- a/apps/api/v1/pages/api/teams/[teamId]/_patch.ts +++ b/apps/api/v1/pages/api/teams/[teamId]/_patch.ts @@ -4,6 +4,7 @@ import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/pay import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; +import { TeamRepository } from "@calcom/lib/server/repository/team"; import prisma from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; @@ -61,24 +62,24 @@ export async function patchHandler(req: NextApiRequest) { const { teamId } = schemaQueryTeamId.parse(req.query); /** Only OWNERS and ADMINS can edit teams */ - const _team = await prisma.team.findFirst({ + const team = await prisma.team.findFirst({ // eslint-disable-next-line @calcom/eslint/no-prisma-include-true include: { members: true }, where: { id: teamId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } }, }); - if (!_team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" }); + if (!team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" }); - const slugAlreadyExists = await prisma.team.findFirst({ - where: { - slug: { - mode: "insensitive", - equals: data.slug, - }, - }, - }); - - if (slugAlreadyExists && data.slug !== _team.slug) - throw new HttpError({ statusCode: 409, message: "Team slug already exists" }); + if (data.slug) { + const teamRepository = new TeamRepository(prisma); + const isSlugAvailable = await teamRepository.isSlugAvailableForUpdate({ + slug: data.slug, + teamId: team.id, + parentId: team.parentId, + }); + if (!isSlugAvailable) { + throw new HttpError({ statusCode: 409, message: "Team slug already exists" }); + } + } // Check if parentId is related to this user if (data.parentId && data.parentId === teamId) { @@ -99,16 +100,16 @@ export async function patchHandler(req: NextApiRequest) { } let paymentUrl; - if (_team.slug === null && data.slug) { + if (team.slug === null && data.slug) { data.metadata = { - ...(_team.metadata as Prisma.JsonObject), + ...(team.metadata as Prisma.JsonObject), requestedSlug: data.slug, }; delete data.slug; if (IS_TEAM_BILLING_ENABLED) { const checkoutSession = await purchaseTeamOrOrgSubscription({ - teamId: _team.id, - seatsUsed: _team.members.length, + teamId: team.id, + seatsUsed: team.members.length, userId, pricePerSeat: null, }); @@ -131,9 +132,9 @@ export async function patchHandler(req: NextApiRequest) { bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits, metadata: data.metadata === null ? {} : data.metadata || undefined, }; - const team = await prisma.team.update({ where: { id: teamId }, data: cloneData }); + const updatedTeam = await prisma.team.update({ where: { id: teamId }, data: cloneData }); const result = { - team: schemaTeamReadPublic.parse(team), + team: schemaTeamReadPublic.parse(updatedTeam), paymentUrl, }; if (!paymentUrl) { diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index d4ac303f0f..604528d419 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -160,6 +160,7 @@ const createTeamAndAddUser = async ( orgRequestedSlug, schedulingType, assignAllTeamMembersForSubTeamEvents, + teamSlug, }: { user: { id: number; email: string; username: string | null; role?: MembershipRole }; isUnpublished?: boolean; @@ -172,12 +173,15 @@ const createTeamAndAddUser = async ( orgRequestedSlug?: string; schedulingType?: SchedulingType; assignAllTeamMembersForSubTeamEvents?: boolean; + teamSlug?: string; }, workerInfo: WorkerInfo ) => { const slugIndex = index ? `-count-${index}` : ""; const slug = - orgRequestedSlug ?? `${isOrg ? "org" : "team"}-${workerInfo.workerIndex}-${Date.now()}${slugIndex}`; + teamSlug ?? + orgRequestedSlug ?? + `${isOrg ? "org" : "team"}-${workerInfo.workerIndex}-${Date.now()}${slugIndex}`; const data: PrismaType.TeamCreateInput = { name: `user-id-${user.id}'s ${isOrg ? "Org" : "Team"}`, isOrganization: isOrg, @@ -283,6 +287,7 @@ export const createUsersFixture = ( schedulingType?: SchedulingType; teamEventTitle?: string; teamEventSlug?: string; + teamSlug?: string; teamEventLength?: number; isOrg?: boolean; isOrgVerified?: boolean; @@ -367,6 +372,7 @@ export const createUsersFixture = ( orgRequestedSlug: scenario.orgRequestedSlug, schedulingType: scenario.schedulingType, assignAllTeamMembersForSubTeamEvents: scenario.assignAllTeamMembersForSubTeamEvents, + teamSlug: scenario?.teamSlug, }, workerInfo ); @@ -465,7 +471,7 @@ export const createUsersFixture = ( }, data: { orgProfiles: _user.profiles.length - ? { + ? { connect: _user.profiles.map((profile) => ({ id: profile.id })), } : { @@ -711,6 +717,31 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { } return membership; }, + getAllTeamMembership: async () => { + const memberships = await prisma.membership.findMany({ + where: { + userId: user.id, + team: { + isOrganization: false, + }, + }, + include: { team: true, user: true }, + }); + + const filteredMemberships = memberships.map((membership) => ({ + ...membership, + team: { + ...membership.team, + metadata: teamMetadataSchema.parse(membership.team.metadata), + }, + })); + + if (filteredMemberships.length === 0) { + throw new Error("No team memberships found for user"); + } + + return filteredMemberships; + }, getOrgMembership: async () => { const membership = await prisma.membership.findFirstOrThrow({ where: { diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts index aaeb1d90b7..4321de6684 100644 --- a/apps/web/playwright/teams.e2e.ts +++ b/apps/web/playwright/teams.e2e.ts @@ -10,6 +10,7 @@ import { confirmReschedule, fillStripeTestCheckout, selectFirstAvailableTimeSlotNextMonth, + submitAndWaitForResponse, testName, } from "./lib/testUtils"; @@ -301,3 +302,108 @@ test.describe("Teams - NonOrg", () => { }); todo("Reschedule a Round Robin EventType booking"); }); + +test.describe("Team Slug Validation", () => { + test.afterEach(({ users, orgs }) => { + users.deleteAll(); + orgs.deleteAll(); + }); + + test("Teams in different organizations can have the same slug", async ({ page, users, orgs }) => { + const org1 = await orgs.create({ name: "Organization 1" }); + const org2 = await orgs.create({ name: "Organization 2" }); + + const owner1 = await users.create( + { + organizationId: org1.id, + roleInOrganization: "OWNER", + }, + { + hasTeam: true, + teamSlug: "cal", + teamRole: "OWNER", + } + ); + + const owner2 = await users.create( + { + organizationId: org2.id, + roleInOrganization: "OWNER", + }, + { + hasTeam: true, + teamSlug: "calCom", + teamRole: "OWNER", + } + ); + const { team: team1 } = await owner1.getFirstTeamMembership(); + + await owner1.apiLogin(); + await page.goto(`/settings/teams/${team1.id}/profile`); + await page.locator('input[name="slug"]').fill("calCom"); + await submitAndWaitForResponse(page, "/api/trpc/teams/update?batch=1", { + action: () => page.locator("[data-testid=update-team-profile]").click(), + }); + }); + + test("Teams within same organization cannot have duplicate slugs", async ({ page, users, orgs }) => { + const org = await orgs.create({ name: "Organization 1" }); + + const owner = await users.create( + { + organizationId: org.id, + roleInOrganization: "OWNER", + }, + { + hasTeam: true, + numberOfTeams: 2, + teamRole: "OWNER", + } + ); + + const teams = await owner.getAllTeamMembership(); + await owner.apiLogin(); + await page.goto(`/settings/teams/${teams[0].team.id}/profile`); + if (!teams[1].team.slug) throw new Error("Slug not found for team 2"); + await page.locator('input[name="slug"]').fill(teams[1].team.slug); + await submitAndWaitForResponse(page, "/api/trpc/teams/update?batch=1", { + action: () => page.locator("[data-testid=update-team-profile]").click(), + expectedStatusCode: 409, + }); + }); + + test("Teams without organization can have same slug as teams in organizations", async ({ + page, + users, + orgs, + }) => { + const org = await orgs.create({ name: "Organization 1" }); + + const orgOwner = await users.create( + { + organizationId: org.id, + roleInOrganization: "OWNER", + }, + { + hasTeam: true, + teamSlug: "calCom", + teamRole: "OWNER", + } + ); + + const teamOwner = await users.create( + { username: "pro-user", name: "pro-user" }, + { + hasTeam: true, + } + ); + + const { team } = await teamOwner.getFirstTeamMembership(); + await teamOwner.apiLogin(); + await page.goto(`/settings/teams/${team.id}/profile`); + await page.locator('input[name="slug"]').fill("calCom"); + await submitAndWaitForResponse(page, "/api/trpc/teams/update?batch=1", { + action: () => page.locator("[data-testid=update-team-profile]").click(), + }); + }); +}); diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx index 8ffd7f4d9a..5fe579cf62 100644 --- a/packages/features/ee/teams/pages/team-profile-view.tsx +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -466,7 +466,12 @@ const TeamProfileForm = ({ team, teamId }: TeamProfileFormProps) => {

{t("team_description")}

- {IS_TEAM_BILLING_ENABLED && diff --git a/packages/lib/server/repository/team.ts b/packages/lib/server/repository/team.ts index 67d89bb787..a522773559 100644 --- a/packages/lib/server/repository/team.ts +++ b/packages/lib/server/repository/team.ts @@ -411,4 +411,30 @@ export class TeamRepository { }, }); } + + async isSlugAvailableForUpdate({ + slug, + teamId, + parentId, + }: { + slug: string; + teamId: number; + parentId?: number | null; + }) { + const whereClause: Prisma.TeamWhereInput = { + slug: { + equals: slug, + mode: "insensitive", + }, + parentId: parentId ?? null, + NOT: { id: teamId }, + }; + + const conflictingTeam = await this.prismaClient.team.findFirst({ + where: whereClause, + select: { id: true }, + }); + + return !conflictingTeam; + } } diff --git a/packages/trpc/server/routers/viewer/teams/update.handler.ts b/packages/trpc/server/routers/viewer/teams/update.handler.ts index 989da1d1dc..7096e52617 100644 --- a/packages/trpc/server/routers/viewer/teams/update.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/update.handler.ts @@ -4,6 +4,7 @@ import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema"; import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder"; import { uploadLogo } from "@calcom/lib/server/avatar"; +import { TeamRepository } from "@calcom/lib/server/repository/team"; import { prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import { MembershipRole, RedirectType, RRTimestampBasis } from "@calcom/prisma/enums"; @@ -39,12 +40,16 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { } if (input.slug) { - const userConflict = await prisma.team.findMany({ - where: { - slug: input.slug, - }, + const orgId = ctx.user.organizationId; + const teamRepository = new TeamRepository(prisma); + const isSlugAvailable = await teamRepository.isSlugAvailableForUpdate({ + slug: input.slug, + teamId: input.id, + parentId: orgId, }); - if (userConflict.some((t) => t.id !== input.id)) return; + if (!isSlugAvailable) { + throw new TRPCError({ code: "CONFLICT", message: "Slug already in use." }); + } } const prevTeam = await prisma.team.findUnique({