Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 20 additions & 19 deletions apps/api/v1/pages/api/teams/[teamId]/_patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) {
Expand All @@ -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,
});
Expand All @@ -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) {
Expand Down
35 changes: 33 additions & 2 deletions apps/web/playwright/fixtures/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ const createTeamAndAddUser = async (
orgRequestedSlug,
schedulingType,
assignAllTeamMembersForSubTeamEvents,
teamSlug,
}: {
user: { id: number; email: string; username: string | null; role?: MembershipRole };
isUnpublished?: boolean;
Expand All @@ -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 ??
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: can we abstract this logic into its own function instead of just using the nullish coalescing operator? this could get messy really fast if in future we might need to update the logic and add some stuff in here

orgRequestedSlug ??
`${isOrg ? "org" : "team"}-${workerInfo.workerIndex}-${Date.now()}${slugIndex}`;
const data: PrismaType.TeamCreateInput = {
name: `user-id-${user.id}'s ${isOrg ? "Org" : "Team"}`,
isOrganization: isOrg,
Expand Down Expand Up @@ -283,6 +287,7 @@ export const createUsersFixture = (
schedulingType?: SchedulingType;
teamEventTitle?: string;
teamEventSlug?: string;
teamSlug?: string;
teamEventLength?: number;
isOrg?: boolean;
isOrgVerified?: boolean;
Expand Down Expand Up @@ -367,6 +372,7 @@ export const createUsersFixture = (
orgRequestedSlug: scenario.orgRequestedSlug,
schedulingType: scenario.schedulingType,
assignAllTeamMembersForSubTeamEvents: scenario.assignAllTeamMembersForSubTeamEvents,
teamSlug: scenario?.teamSlug,
},
workerInfo
);
Expand Down Expand Up @@ -465,7 +471,7 @@ export const createUsersFixture = (
},
data: {
orgProfiles: _user.profiles.length
? {
? {
connect: _user.profiles.map((profile) => ({ id: profile.id })),
}
: {
Expand Down Expand Up @@ -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: {
Expand Down
106 changes: 106 additions & 0 deletions apps/web/playwright/teams.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
confirmReschedule,
fillStripeTestCheckout,
selectFirstAvailableTimeSlotNextMonth,
submitAndWaitForResponse,
testName,
} from "./lib/testUtils";

Expand Down Expand Up @@ -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(),
});
});
});
7 changes: 6 additions & 1 deletion packages/features/ee/teams/pages/team-profile-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
23 changes: 23 additions & 0 deletions packages/lib/server/repository/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,4 +411,27 @@ export class TeamRepository {
},
});
}

async isSlugAvailableForUpdate({
slug,
teamId,
parentId,
}: {
slug: string;
teamId: number;
parentId?: number | null;
}) {
const whereClause: Prisma.TeamWhereInput = {
slug,
parentId: parentId ?? null,
NOT: { id: teamId },
};

const conflictingTeam = await this.prismaClient.team.findFirst({
where: whereClause,
select: { id: true },
});

return !conflictingTeam;
}
}
15 changes: 10 additions & 5 deletions packages/trpc/server/routers/viewer/teams/update.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down
Loading