diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index e6c968a208..bbe3506714 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -9,6 +9,18 @@ import type { EventType, User, WorkflowsOnEventTypes } from "@calcom/prisma/clie import type { Prisma } from "@calcom/prisma/client"; import { SchedulingType } from "@calcom/prisma/enums"; +// Helper to setup transaction mock that executes the callback with the prisma mock +const setupTransactionMock = () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prismaMock.$transaction.mockImplementation(async (callback) => { + if (typeof callback === "function") { + return await callback(prismaMock); + } + return Promise.all(callback); + }); +}; + // create input does not allow ID const mockFindFirstEventType = ( data?: Partial & { workflows?: WorkflowsOnEventTypes[] } & { users?: User[] } @@ -132,6 +144,13 @@ describe("handleChildrenEventTypes", () => { metadata: { managedEventConfig: {} }, locations: [], }); + + // Setup transaction mock to execute the callback + setupTransactionMock(); + + // Mock findMany to return empty array (no workflows to link) + prismaMock.eventType.findMany.mockResolvedValue([]); + const result = await updateChildrenEventTypes({ eventTypeId: 1, oldEventType: { children: [], team: { name: "" } }, @@ -143,25 +162,27 @@ describe("handleChildrenEventTypes", () => { updatedValues: {}, }); const { createdAt, updatedAt, ...expectedEvType } = evType; - expect(prismaMock.eventType.create).toHaveBeenCalledWith({ - data: { - ...expectedEvType, - parentId: 1, - users: { connect: [{ id: 4 }] }, - lockTimeZoneToggleOnBookingPage: false, - requiresBookerEmailVerification: false, - bookingLimits: undefined, - durationLimits: undefined, - recurringEvent: undefined, - eventTypeColor: undefined, - customReplyToEmail: null, - userId: 4, - rrSegmentQueryValue: undefined, - assignRRMembersUsingSegment: false, - useBookerTimezone: false, - restrictionScheduleId: null, - allowReschedulingCancelledBookings: false, - }, + expect(prismaMock.eventType.createMany).toHaveBeenCalledWith({ + data: [ + { + ...expectedEvType, + parentId: 1, + lockTimeZoneToggleOnBookingPage: false, + requiresBookerEmailVerification: false, + bookingLimits: undefined, + durationLimits: undefined, + recurringEvent: undefined, + eventTypeColor: undefined, + customReplyToEmail: null, + userId: 4, + rrSegmentQueryValue: undefined, + assignRRMembersUsingSegment: false, + useBookerTimezone: false, + restrictionScheduleId: null, + allowReschedulingCancelledBookings: false, + }, + ], + skipDuplicates: true, }); expect(result.newUserIds).toEqual([4]); expect(result.oldUserIds).toEqual([]); @@ -304,6 +325,13 @@ describe("handleChildrenEventTypes", () => { metadata: { managedEventConfig: {} }, locations: [], }); + + // Setup transaction mock to execute the callback + setupTransactionMock(); + + // Mock findMany to return empty array (no workflows to link) + prismaMock.eventType.findMany.mockResolvedValue([]); + prismaMock.eventType.deleteMany.mockResolvedValue([123] as unknown as Prisma.BatchPayload); const result = await updateChildrenEventTypes({ eventTypeId: 1, @@ -316,27 +344,28 @@ describe("handleChildrenEventTypes", () => { updatedValues: {}, }); const { createdAt, updatedAt, ...expectedEvType } = evType; - expect(prismaMock.eventType.create).toHaveBeenCalledWith({ - data: { - ...expectedEvType, - parentId: 1, - users: { connect: [{ id: 4 }] }, - bookingLimits: undefined, - durationLimits: undefined, - recurringEvent: undefined, - eventTypeColor: undefined, - customReplyToEmail: null, - instantMeetingScheduleId: undefined, - lockTimeZoneToggleOnBookingPage: false, - requiresBookerEmailVerification: false, - userId: 4, - workflows: undefined, - rrSegmentQueryValue: undefined, - assignRRMembersUsingSegment: false, - useBookerTimezone: false, - restrictionScheduleId: null, - allowReschedulingCancelledBookings: false, - }, + expect(prismaMock.eventType.createMany).toHaveBeenCalledWith({ + data: [ + { + ...expectedEvType, + parentId: 1, + bookingLimits: undefined, + durationLimits: undefined, + recurringEvent: undefined, + eventTypeColor: undefined, + customReplyToEmail: null, + instantMeetingScheduleId: undefined, + lockTimeZoneToggleOnBookingPage: false, + requiresBookerEmailVerification: false, + userId: 4, + rrSegmentQueryValue: undefined, + assignRRMembersUsingSegment: false, + useBookerTimezone: false, + restrictionScheduleId: null, + allowReschedulingCancelledBookings: false, + }, + ], + skipDuplicates: true, }); expect(result.newUserIds).toEqual([4]); expect(result.oldUserIds).toEqual([]); @@ -439,6 +468,96 @@ describe("handleChildrenEventTypes", () => { ], }); + // Setup transaction mock to execute the callback + setupTransactionMock(); + + // Mock findMany to return the newly created event type for workflow linking + // This simulates the event type created for user 5 + prismaMock.eventType.findMany.mockResolvedValue([ + { + id: 3, + title: "test", + slug: "test", + description: null, + position: 0, + locations: [], + length: 30, + offsetStart: 0, + hidden: false, + userId: 5, + profileId: null, + teamId: null, + eventName: null, + parentId: 1, + bookingFields: null, + timeZone: null, + periodType: "UNLIMITED", + periodStartDate: null, + periodEndDate: null, + periodDays: null, + periodCountCalendarDays: null, + lockTimeZoneToggleOnBookingPage: false, + lockedTimeZone: null, + requiresConfirmation: false, + requiresConfirmationWillBlockSlot: false, + requiresConfirmationForFreeEmail: false, + requiresBookerEmailVerification: false, + canSendCalVideoTranscriptionEmails: true, + autoTranslateDescriptionEnabled: false, + recurringEvent: null, + disableGuests: false, + hideCalendarNotes: false, + hideCalendarEventDetails: false, + minimumBookingNotice: 120, + beforeEventBuffer: 0, + afterEventBuffer: 0, + seatsPerTimeSlot: null, + onlyShowFirstAvailableSlot: false, + showOptimizedSlots: false, + disableCancelling: false, + disableRescheduling: false, + seatsShowAttendees: false, + seatsShowAvailabilityCount: true, + schedulingType: null, + scheduleId: null, + price: 0, + currency: "usd", + slotInterval: null, + metadata: {}, + successRedirectUrl: null, + forwardParamsSuccessRedirect: true, + bookingLimits: null, + durationLimits: null, + isInstantEvent: false, + instantMeetingExpiryTimeOffsetInSeconds: 90, + instantMeetingScheduleId: null, + assignAllTeamMembers: false, + useEventTypeDestinationCalendarEmail: false, + secondaryEmailId: null, + eventTypeColor: null, + rescheduleWithSameRoundRobinHost: false, + rrSegmentQueryValue: null, + assignRRMembersUsingSegment: false, + useEventLevelSelectedCalendars: false, + restrictionScheduleId: null, + useBookerTimezone: false, + allowReschedulingCancelledBookings: false, + includeNoShowInRRCalculation: false, + interfaceLanguage: null, + customReplyToEmail: null, + createdAt: new Date(), + updatedAt: new Date(), + instantMeetingParameters: [], + isRRWeightsEnabled: false, + maxLeadThreshold: null, + allowReschedulingPastBookings: false, + hideOrganizerEmail: false, + maxActiveBookingsPerBooker: null, + maxActiveBookingPerBookerOfferReschedule: false, + bookingRequiresAuthentication: false, + }, + ]); + // Mock the event type that will be returned for existing users const mockUpdatedEventType = { id: 2, @@ -481,33 +600,41 @@ describe("handleChildrenEventTypes", () => { }); const { createdAt, updatedAt, ...expectedEvType } = evType; - expect(prismaMock.eventType.create).toHaveBeenCalledWith({ - data: { - ...expectedEvType, - bookingLimits: undefined, - durationLimits: undefined, - recurringEvent: undefined, - eventTypeColor: undefined, - customReplyToEmail: null, - locations: [], - lockTimeZoneToggleOnBookingPage: false, - requiresBookerEmailVerification: false, - useBookerTimezone: false, - restrictionScheduleId: null, - parentId: 1, - userId: 5, - users: { - connect: [{ id: 5 }], + if ("workflows" in expectedEvType) delete expectedEvType.workflows; + // Verify createMany was called for new users (user 5) + // Note: createMany doesn't support nested relations like workflows, so they're handled separately + expect(prismaMock.eventType.createMany).toHaveBeenCalledWith({ + data: [ + { + ...expectedEvType, + bookingLimits: undefined, + durationLimits: undefined, + recurringEvent: undefined, + eventTypeColor: undefined, + customReplyToEmail: null, + locations: [], + lockTimeZoneToggleOnBookingPage: false, + requiresBookerEmailVerification: false, + useBookerTimezone: false, + restrictionScheduleId: null, + instantMeetingScheduleId: undefined, + parentId: 1, + userId: 5, + rrSegmentQueryValue: undefined, + assignRRMembersUsingSegment: false, + useEventLevelSelectedCalendars: false, + allowReschedulingCancelledBookings: false, }, - workflows: { - create: [{ workflowId: 11 }], - }, - rrSegmentQueryValue: undefined, - assignRRMembersUsingSegment: false, - useEventLevelSelectedCalendars: false, - allowReschedulingCancelledBookings: false, - }, + ], + skipDuplicates: true, }); + + // Verify workflowsOnEventTypes.createMany was called for new users' workflows + expect(prismaMock.workflowsOnEventTypes.createMany).toHaveBeenCalledWith({ + data: [{ eventTypeId: 3, workflowId: 11 }], + skipDuplicates: true, + }); + const { profileId, rrSegmentQueryValue, createdAt: _, updatedAt: __, ...rest } = evType; if ("workflows" in rest) delete rest.workflows; expect(prismaMock.eventType.update).toHaveBeenCalledWith({ @@ -531,6 +658,7 @@ describe("handleChildrenEventTypes", () => { }, }, }); + // Verify workflowsOnEventTypes.upsert was called for existing users' workflows expect(prismaMock.workflowsOnEventTypes.upsert).toHaveBeenCalledWith({ create: { eventTypeId: 2, diff --git a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts index f21c455a3d..0cf0994734 100644 --- a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts +++ b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts @@ -1,4 +1,3 @@ - import type { DeepMockProxy } from "vitest-mock-extended"; import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils"; @@ -155,60 +154,94 @@ export default async function handleChildrenEventTypes({ userIds: newUserIds, teamName: oldEventType.team?.name ?? null, }); - // Create event types for new users added - await prisma.$transaction( - newUserIds.map((userId) => { - // Exclude profileId and instantMeetingScheduleId from managed values to avoid duplication - const { - profileId: _, - instantMeetingScheduleId: __, - ...managedValuesWithoutExplicit - } = managedEventTypeValues; - return prisma.eventType.create({ - data: { - instantMeetingScheduleId: eventType.instantMeetingScheduleId ?? undefined, - profileId: profileId ?? null, - ...managedValuesWithoutExplicit, - ...{ - ...unlockedEventTypeValues, - // pre-genned as allowed null - locations: Array.isArray(unlockedEventTypeValues.locations) - ? unlockedEventTypeValues.locations - : undefined, + // Create event types for new users added + const eventTypesToCreateData = newUserIds.map((userId) => { + // Exclude profileId and instantMeetingScheduleId from managed values to avoid duplication + const { + profileId: _, + instantMeetingScheduleId: __, + ...managedValuesWithoutExplicit + } = managedEventTypeValues; + + return { + instantMeetingScheduleId: eventType.instantMeetingScheduleId ?? undefined, + profileId: profileId ?? null, + ...managedValuesWithoutExplicit, + ...{ + ...unlockedEventTypeValues, + // pre-genned as allowed null + locations: Array.isArray(unlockedEventTypeValues.locations) + ? unlockedEventTypeValues.locations + : undefined, + }, + bookingLimits: + (managedEventTypeValues.bookingLimits as unknown as Prisma.InputJsonObject) ?? undefined, + recurringEvent: + (managedEventTypeValues.recurringEvent as unknown as Prisma.InputJsonValue) ?? undefined, + metadata: (managedEventTypeValues.metadata as Prisma.InputJsonValue) ?? undefined, + bookingFields: (managedEventTypeValues.bookingFields as Prisma.InputJsonValue) ?? undefined, + durationLimits: (managedEventTypeValues.durationLimits as Prisma.InputJsonValue) ?? undefined, + eventTypeColor: (managedEventTypeValues.eventTypeColor as Prisma.InputJsonValue) ?? undefined, + onlyShowFirstAvailableSlot: managedEventTypeValues.onlyShowFirstAvailableSlot ?? false, + userId, + parentId, + hidden: children?.find((ch) => ch.owner.id === userId)?.hidden ?? false, + /** + * RR Segment isn't applicable for managed event types. + */ + rrSegmentQueryValue: undefined, + assignRRMembersUsingSegment: false, + useEventLevelSelectedCalendars: false, + restrictionScheduleId: null, + useBookerTimezone: false, + allowReschedulingCancelledBookings: + managedEventTypeValues.allowReschedulingCancelledBookings ?? false, + }; + }); + + await prisma.$transaction(async (tx) => { + await tx.eventType.createMany({ + data: eventTypesToCreateData, + skipDuplicates: true, + }); + + // Fetch the newly created event types to connect users and workflows + const createdEvents = await tx.eventType.findMany({ + where: { parentId: parentId, userId: { in: newUserIds } }, + select: { id: true, userId: true }, + }); + + // Connect users to their event types (many-to-many relation) + // This is needed because createMany doesn't support nested relations + await Promise.all( + createdEvents.map((event) => + tx.eventType.update({ + where: { id: event.id }, + data: { + users: { + connect: [{ id: event.userId! }], + }, }, - bookingLimits: - (managedEventTypeValues.bookingLimits as unknown as Prisma.InputJsonObject) ?? undefined, - recurringEvent: - (managedEventTypeValues.recurringEvent as unknown as Prisma.InputJsonValue) ?? undefined, - metadata: (managedEventTypeValues.metadata as Prisma.InputJsonValue) ?? undefined, - bookingFields: (managedEventTypeValues.bookingFields as Prisma.InputJsonValue) ?? undefined, - durationLimits: (managedEventTypeValues.durationLimits as Prisma.InputJsonValue) ?? undefined, - eventTypeColor: (managedEventTypeValues.eventTypeColor as Prisma.InputJsonValue) ?? undefined, - onlyShowFirstAvailableSlot: managedEventTypeValues.onlyShowFirstAvailableSlot ?? false, - userId, - users: { - connect: [{ id: userId }], - }, - parentId, - hidden: children?.find((ch) => ch.owner.id === userId)?.hidden ?? false, - workflows: currentWorkflowIds && { - create: currentWorkflowIds.map((wfId) => ({ workflowId: wfId })), - }, - /** - * RR Segment isn't applicable for managed event types. - */ - rrSegmentQueryValue: undefined, - assignRRMembersUsingSegment: false, - useEventLevelSelectedCalendars: false, - restrictionScheduleId: null, - useBookerTimezone: false, - allowReschedulingCancelledBookings: - managedEventTypeValues.allowReschedulingCancelledBookings ?? false, - }, + }) + ) + ); + + // Link workflows if any exist + if (currentWorkflowIds && currentWorkflowIds.length > 0) { + const workflowConnections = createdEvents.flatMap((event) => + currentWorkflowIds.map((wfId) => ({ + eventTypeId: event.id, + workflowId: wfId, + })) + ); + + await tx.workflowsOnEventTypes.createMany({ + data: workflowConnections, + skipDuplicates: true, }); - }) - ); + } + }); } // Old users updated