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 <me@alexvanandel.com>
This commit is contained in:
Anik Dhabal Babu
2025-10-01 14:04:20 +00:00
committed by GitHub
co-authored by Udit Takkar Alex van Andel
parent 8019c414e1
commit ff264d6f7a
6 changed files with 201 additions and 27 deletions
+20 -19
View File
@@ -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) {
+33 -2
View File
@@ -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: {
+106
View File
@@ -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(),
});
});
});
@@ -466,7 +466,12 @@ const TeamProfileForm = ({ team, teamId }: TeamProfileFormProps) => {
<p className="text-default mt-2 text-sm">{t("team_description")}</p>
</div>
<SectionBottomActions align="end">
<Button color="primary" type="submit" loading={mutation.isPending} disabled={isDisabled}>
<Button
color="primary"
type="submit"
loading={mutation.isPending}
disabled={isDisabled}
data-testid="update-team-profile">
{t("update")}
</Button>
{IS_TEAM_BILLING_ENABLED &&
+26
View File
@@ -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;
}
}
@@ -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({