feat: add duplicate functionality for managed event types (#26792)

* feat: add duplicate functionality for managed event types

Co-Authored-By: peer@cal.com <peer@cal.com>

* feat: add disclaimer and redirect for managed event type duplication

- Add info alert disclaimer in duplicate modal for managed event types
- Pass schedulingType to duplicate dialog via query params
- Redirect to assignment tab after duplicating managed event types
- Add translation key for managed event type duplicate disclaimer

Co-Authored-By: peer@cal.com <peer@cal.com>

* test: add tests for managed event type duplication

Co-Authored-By: peer@cal.com <peer@cal.com>

* refactor: replace PrismaMock unit test with Prisma integration test for duplicate handler (#27973)

* refactor: replace PrismaMock unit test with Prisma integration test for duplicate handler

Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com>

* fix: re-throw TRPCErrors in duplicate handler catch block and assert specific error codes in tests

Co-Authored-By: ali@cal.com <alishahbaz7@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <1780212+hariombalhara@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
This commit is contained in:
Peer Richelsen
2026-02-18 16:49:23 +04:00
committed by GitHub
co-authored by peer@cal.com ali@cal.com Devin AI Hariom Balhara Syed Ali Shahbaz
parent 8908a66c23
commit eef47ddefd
5 changed files with 322 additions and 24 deletions
@@ -15,6 +15,8 @@ import { md } from "@calcom/lib/markdownIt";
import slugify from "@calcom/lib/slugify";
import turndown from "@calcom/lib/turndownService";
import { trpc } from "@calcom/trpc/react";
import { SchedulingType } from "@calcom/prisma/enums";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import { Editor } from "@calcom/ui/components/editor";
@@ -32,6 +34,7 @@ const querySchema = z.object({
pageSlug: z.string(),
teamId: z.coerce.number().optional().nullable(),
parentId: z.coerce.number().optional().nullable(),
schedulingType: z.nativeEnum(SchedulingType).optional().nullable(),
});
const DuplicateDialog = () => {
@@ -42,8 +45,9 @@ const DuplicateDialog = () => {
const router = useRouter();
const [firstRender, setFirstRender] = useState(true);
const {
data: { pageSlug, slug, ...defaultValues },
data: { pageSlug, slug, schedulingType, ...defaultValues },
} = useTypedQuery(querySchema);
const isManagedEventType = schedulingType === SchedulingType.MANAGED;
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearchTerm = useDebounce(searchTerm, 500);
@@ -72,7 +76,11 @@ const DuplicateDialog = () => {
const duplicateMutation = trpc.viewer.eventTypesHeavy.duplicate.useMutation({
onSuccess: async ({ eventType }) => {
await router.replace(`/event-types/${eventType.id}`);
// For managed event types, redirect to the assignment tab so the user can add members
const redirectUrl = isManagedEventType
? `/event-types/${eventType.id}?tabName=team`
: `/event-types/${eventType.id}`;
await router.replace(redirectUrl);
await utils.viewer.eventTypes.getUserEventGroups.invalidate();
revalidateEventTypesList();
@@ -117,7 +125,7 @@ const DuplicateDialog = () => {
return (
<Dialog
name="duplicate"
clearQueryParamsOnClose={["description", "title", "length", "slug", "name", "id", "pageSlug"]}>
clearQueryParamsOnClose={["description", "title", "length", "slug", "name", "id", "pageSlug", "schedulingType"]}>
<DialogContent type="creation" className="overflow-y-auto" title={t("duplicate_event_type")}>
<Form
form={form}
@@ -125,6 +133,12 @@ const DuplicateDialog = () => {
duplicateMutation.mutate(values);
}}>
<div className="-mt-2 stack-y-5">
{isManagedEventType && (
<Alert
severity="info"
message={t("managed_event_type_duplicate_disclaimer")}
/>
)}
<TextField
label={t("title")}
placeholder={t("quick_chat")}
@@ -432,14 +432,15 @@ export const InfiniteEventTypeList = ({
if (value) newSearchParams.set(key, value.toString());
if (value === null) newSearchParams.delete(key);
}
setParamsIfDefined("dialog", "duplicate");
setParamsIfDefined("title", eventType.title);
setParamsIfDefined("description", eventType.description);
setParamsIfDefined("slug", eventType.slug);
setParamsIfDefined("id", eventType.id);
setParamsIfDefined("length", eventType.length);
setParamsIfDefined("pageSlug", group.profile.slug);
router.push(`${pathname}?${newSearchParams.toString()}`);
setParamsIfDefined("dialog", "duplicate");
setParamsIfDefined("title", eventType.title);
setParamsIfDefined("description", eventType.description);
setParamsIfDefined("slug", eventType.slug);
setParamsIfDefined("id", eventType.id);
setParamsIfDefined("length", eventType.length);
setParamsIfDefined("pageSlug", group.profile.slug);
setParamsIfDefined("schedulingType", eventType.schedulingType);
router.push(`${pathname}?${newSearchParams.toString()}`);
};
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
@@ -695,18 +696,16 @@ export const InfiniteEventTypeList = ({
</DropdownMenuItem>
)}
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
<>
<DropdownMenuItem className="outline-none">
<DropdownItem
type="button"
data-testid={`event-type-duplicate-${type.id}`}
StartIcon="copy"
onClick={() => openDuplicateModal(type, group)}>
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
</>
{!readOnly && !isChildrenManagedEventType && (
<DropdownMenuItem className="outline-none">
<DropdownItem
type="button"
data-testid={`event-type-duplicate-${type.id}`}
StartIcon="copy"
onClick={() => openDuplicateModal(type, group)}>
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
)}
{!isManagedEventType && (
<DropdownMenuItem className="outline-none">
@@ -812,7 +811,7 @@ export const InfiniteEventTypeList = ({
</DropdownItem>
</DropdownMenuItem>
)}
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
{!readOnly && !isChildrenManagedEventType && (
<DropdownMenuItem className="outline-none">
<DropdownItem
onClick={() => openDuplicateModal(type, group)}
@@ -228,6 +228,7 @@
"time_left": "Time left",
"event_type": "Event type",
"duplicate_event_type": "Duplicate event type",
"managed_event_type_duplicate_disclaimer": "User assignments are not copied when duplicating a managed event type. You will need to add team members manually after duplication.",
"enter_meeting": "Enter meeting",
"video_call_provider": "Video call provider",
"meeting_id": "Meeting ID",
@@ -0,0 +1,281 @@
import prisma from "@calcom/prisma";
import type { EventType, Team, User } from "@calcom/prisma/client";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { duplicateHandler } from "./duplicate.handler";
const timestamp = Date.now();
function createCtx(user: User) {
return {
user: {
id: user.id,
profile: {
id: null,
},
} as NonNullable<TrpcSessionUser>,
};
}
let user1: User;
let user2: User;
let user3: User;
let team1: Team;
let individualEventType: EventType;
let managedParentEventType: EventType;
const duplicatedEventTypeIds: number[] = [];
describe("duplicateHandler - integration", () => {
beforeAll(async () => {
user1 = await prisma.user.create({
data: {
username: `dup-test-user1-${timestamp}`,
email: `dup-test-user1-${timestamp}@example.com`,
name: "Dup Test User 1",
},
});
user2 = await prisma.user.create({
data: {
username: `dup-test-user2-${timestamp}`,
email: `dup-test-user2-${timestamp}@example.com`,
name: "Dup Test User 2",
},
});
user3 = await prisma.user.create({
data: {
username: `dup-test-user3-${timestamp}`,
email: `dup-test-user3-${timestamp}@example.com`,
name: "Dup Test User 3",
},
});
team1 = await prisma.team.create({
data: {
name: `Dup Test Team ${timestamp}`,
slug: `dup-test-team-${timestamp}`,
members: {
createMany: {
data: [
{ userId: user1.id, role: MembershipRole.ADMIN, accepted: true },
{ userId: user2.id, role: MembershipRole.MEMBER, accepted: true },
],
},
},
},
});
individualEventType = await prisma.eventType.create({
data: {
title: "Individual Event",
slug: `dup-individual-${timestamp}`,
length: 30,
userId: user1.id,
},
});
managedParentEventType = await prisma.eventType.create({
data: {
title: "Managed Parent Event",
slug: `dup-managed-parent-${timestamp}`,
length: 45,
teamId: team1.id,
schedulingType: SchedulingType.MANAGED,
hosts: {
create: {
userId: user1.id,
isFixed: false,
priority: 2,
weight: 100,
weightAdjustment: 0,
},
},
},
});
});
afterAll(async () => {
try {
if (duplicatedEventTypeIds.length > 0) {
await prisma.host.deleteMany({ where: { eventTypeId: { in: duplicatedEventTypeIds } } });
await prisma.eventType.deleteMany({ where: { id: { in: duplicatedEventTypeIds } } });
}
const sourceIds = [individualEventType?.id, managedParentEventType?.id].filter(Boolean);
if (sourceIds.length > 0) {
await prisma.host.deleteMany({ where: { eventTypeId: { in: sourceIds } } });
await prisma.eventType.deleteMany({ where: { id: { in: sourceIds } } });
}
if (team1?.id) {
await prisma.membership.deleteMany({ where: { teamId: team1.id } });
await prisma.team.deleteMany({ where: { id: team1.id } });
}
const userIds = [user1?.id, user2?.id, user3?.id].filter(Boolean);
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
}
} catch (error) {
console.warn("Test cleanup failed:", error);
}
});
it("should duplicate an individual event type", async () => {
const result = await duplicateHandler({
ctx: createCtx(user1),
input: {
id: individualEventType.id,
slug: `dup-individual-copy-${timestamp}`,
title: "Individual Event Copy",
description: "A copy of the individual event",
length: 30,
},
});
duplicatedEventTypeIds.push(result.eventType.id);
expect(result.eventType.id).not.toBe(individualEventType.id);
expect(result.eventType.title).toBe("Individual Event Copy");
expect(result.eventType.slug).toBe(`dup-individual-copy-${timestamp}`);
expect(result.eventType.length).toBe(30);
});
it("should preserve schedulingType MANAGED when duplicating", async () => {
const result = await duplicateHandler({
ctx: createCtx(user1),
input: {
id: managedParentEventType.id,
slug: `dup-managed-copy-${timestamp}`,
title: "Managed Event Copy",
description: "A copy of managed event",
length: 45,
teamId: team1.id,
},
});
duplicatedEventTypeIds.push(result.eventType.id);
expect(result.eventType.schedulingType).toBe(SchedulingType.MANAGED);
});
it("should copy hosts but not children when duplicating a managed event type", async () => {
const result = await duplicateHandler({
ctx: createCtx(user1),
input: {
id: managedParentEventType.id,
slug: `dup-managed-hosts-${timestamp}`,
title: "Managed Event Hosts Copy",
description: "Copy to test hosts",
length: 45,
teamId: team1.id,
},
});
duplicatedEventTypeIds.push(result.eventType.id);
const duplicatedHosts = await prisma.host.findMany({
where: { eventTypeId: result.eventType.id },
});
expect(duplicatedHosts.length).toBeGreaterThan(0);
expect(duplicatedHosts[0].userId).toBe(user1.id);
const children = await prisma.eventType.findMany({
where: { parentId: result.eventType.id },
});
expect(children).toHaveLength(0);
});
it("should set parentId to null for duplicated managed event type", async () => {
const result = await duplicateHandler({
ctx: createCtx(user1),
input: {
id: managedParentEventType.id,
slug: `dup-managed-parentid-${timestamp}`,
title: "Managed Event ParentId Check",
description: "Check parentId is null",
length: 45,
teamId: team1.id,
},
});
duplicatedEventTypeIds.push(result.eventType.id);
const duplicated = await prisma.eventType.findUnique({
where: { id: result.eventType.id },
});
expect(duplicated?.parentId).toBeNull();
});
it("should allow team members to duplicate managed event types", async () => {
const result = await duplicateHandler({
ctx: createCtx(user2),
input: {
id: managedParentEventType.id,
slug: `dup-managed-member-${timestamp}`,
title: "Managed Event Member Copy",
description: "Duplicated by team member",
length: 45,
teamId: team1.id,
},
});
duplicatedEventTypeIds.push(result.eventType.id);
expect(result.eventType).toBeDefined();
expect(result.eventType.schedulingType).toBe(SchedulingType.MANAGED);
});
it("should throw FORBIDDEN when non-team member tries to duplicate managed event type", async () => {
await expect(
duplicateHandler({
ctx: createCtx(user3),
input: {
id: managedParentEventType.id,
slug: `dup-managed-nonmember-${timestamp}`,
title: "Should Not Duplicate",
description: "Non-member attempting to duplicate",
length: 45,
teamId: team1.id,
},
})
).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("should throw CONFLICT when duplicating with an existing slug", async () => {
const conflictSlug = `dup-slug-conflict-${timestamp}`;
const existingEventType = await prisma.eventType.create({
data: {
title: "Existing Event",
slug: conflictSlug,
length: 30,
userId: user1.id,
},
});
try {
await expect(
duplicateHandler({
ctx: createCtx(user1),
input: {
id: individualEventType.id,
slug: conflictSlug,
title: "Should Conflict",
description: "Slug conflict test",
length: 30,
},
})
).rejects.toMatchObject({
code: "CONFLICT",
});
} finally {
await prisma.eventType.delete({ where: { id: existingEventType.id } });
}
});
});
@@ -225,6 +225,9 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => {
eventType: newEventType,
};
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
if (Array.isArray(error.meta?.target) && error.meta?.target.includes("slug")) {
throw new TRPCError({