perf: handleChildrenEventTypes transaction (#25602)
* perf: handleChildrenEventTypes transaction * fixup! perf: handleChildrenEventTypes transaction * fix: e2e tests * test: update handleChildrenEventTypes tests for createMany API Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: tests --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
morgan@cal.com
Devin AI
parent
8cace7f7a0
commit
dea6ec5003
@@ -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<EventType> & { 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user