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:
co-authored by
Udit Takkar
Alex van Andel
parent
8019c414e1
commit
ff264d6f7a
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user