From eef47ddefd93a0cfe37bd0d2c314c86b7581dc75 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Wed, 18 Feb 2026 13:49:23 +0100 Subject: [PATCH] feat: add duplicate functionality for managed event types (#26792) * feat: add duplicate functionality for managed event types Co-Authored-By: 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 * test: add tests for managed event type duplication Co-Authored-By: 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 * fix: re-throw TRPCErrors in duplicate handler catch block and assert specific error codes in tests Co-Authored-By: ali@cal.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> --- .../components/DuplicateDialog.tsx | 20 +- .../views/event-types-listing-view.tsx | 41 ++- apps/web/public/static/locales/en/common.json | 1 + .../duplicate.handler.integration-test.ts | 281 ++++++++++++++++++ .../eventTypes/heavy/duplicate.handler.ts | 3 + 5 files changed, 322 insertions(+), 24 deletions(-) create mode 100644 packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.integration-test.ts diff --git a/apps/web/modules/event-types/components/DuplicateDialog.tsx b/apps/web/modules/event-types/components/DuplicateDialog.tsx index b22f68f90f..fc21852b5d 100644 --- a/apps/web/modules/event-types/components/DuplicateDialog.tsx +++ b/apps/web/modules/event-types/components/DuplicateDialog.tsx @@ -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 ( + clearQueryParamsOnClose={["description", "title", "length", "slug", "name", "id", "pageSlug", "schedulingType"]}>
{ duplicateMutation.mutate(values); }}>
+ {isManagedEventType && ( + + )} )} {/* 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 && ( - <> - - openDuplicateModal(type, group)}> - {t("duplicate")} - - - + {!readOnly && !isChildrenManagedEventType && ( + + openDuplicateModal(type, group)}> + {t("duplicate")} + + )} {!isManagedEventType && ( @@ -812,7 +811,7 @@ export const InfiniteEventTypeList = ({ )} - {!readOnly && !isManagedEventType && !isChildrenManagedEventType && ( + {!readOnly && !isChildrenManagedEventType && ( openDuplicateModal(type, group)} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4479a9788e..8890fa4355 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.integration-test.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.integration-test.ts new file mode 100644 index 0000000000..1fd95a6205 --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.integration-test.ts @@ -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, + }; +} + +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 } }); + } + }); +}); diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts index 5288c9249a..52a6f2b8c2 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/duplicate.handler.ts @@ -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({