From f2a44279fe6802d4ad692ecdc9a04700b02c1b0a Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Thu, 19 Mar 2026 00:20:48 +0530 Subject: [PATCH] refactor: evt calendar event builder (#27203) * refactor: evt creation * refactor: evt creation * refactor: evt creation * fix: type * test: update unit tests * chore: add comments * fix: handle undefined in withConditional to match NonNullable type Co-Authored-By: unknown <> * refactor: evt * fix: type err * refactor: remove add videoCallData * refactor: use error with code * test: update unit test * refactor: feedback * refactor: remove conditional * refactor: more strict types * fix: type err * chor: remove un used * refactor: improve recurring event handling in RegularBookingService Removed unnecessary whitespace and clarified comments regarding the attachment of recurring configurations in the booking handler. Adjusted logic to ensure that recurring settings are only applied when relevant. * refactor: test * fix: type error * refactor: improve code --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../features/CalendarEventBuilder.test.ts | 412 +++++------------- packages/features/CalendarEventBuilder.ts | 175 +++----- .../addVideoCallDataToEvent.ts | 24 - .../test/buildEventForTeamEventType.test.ts | 194 --------- .../test/computeTeamData.test.ts | 176 ++++++++ .../attendeeRescheduleSeatedBooking.ts | 6 +- .../owner/combineTwoSeatedBookings.ts | 6 +- .../owner/moveSeatedBookingToNewTimeSlot.ts | 6 +- .../bookings/lib/handleSeats/types.d.ts | 5 +- .../lib/service/RegularBookingService.ts | 241 ++++------ .../ManagedEventManualReassignmentService.ts | 68 ++- .../tasks/crm/lib/buildCalendarEvent.ts | 24 +- 12 files changed, 512 insertions(+), 825 deletions(-) delete mode 100644 packages/features/bookings/lib/handleNewBooking/addVideoCallDataToEvent.ts delete mode 100644 packages/features/bookings/lib/handleNewBooking/test/buildEventForTeamEventType.test.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/test/computeTeamData.test.ts diff --git a/packages/features/CalendarEventBuilder.test.ts b/packages/features/CalendarEventBuilder.test.ts index 8b71336eed..33889f360d 100644 --- a/packages/features/CalendarEventBuilder.test.ts +++ b/packages/features/CalendarEventBuilder.test.ts @@ -1,7 +1,8 @@ import dayjs from "@calcom/dayjs"; -import { type BookingForCalEventBuilder, CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder"; +import type { BookingForCalEventBuilder } from "@calcom/features/CalendarEventBuilder"; +import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder"; import { TimeFormat } from "@calcom/lib/timeFormat"; -import type { Person } from "@calcom/types/Calendar"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import type { TFunction } from "i18next"; import { describe, expect, it, vi } from "vitest"; @@ -32,18 +33,42 @@ describe("CalendarEventBuilder", () => { const mockTranslate = vi.fn(() => "foo") as TFunction; const mockStartTime = dayjs().add(1, "day").format(); const mockEndTime = dayjs().add(1, "day").add(30, "minutes").format(); + const defaultOrganizer = { + id: 123, + name: "Organizer", + email: "organizer@example.com", + timeZone: "America/New_York", + language: { + translate: mockTranslate, + locale: "en", + }, + }; + const defaultAttendees = [ + { + name: "Attendee", + email: "attendee@example.com", + timeZone: "Europe/London", + language: { + translate: mockTranslate, + locale: "en", + }, + }, + ]; + const createBuilder = (overrides: Partial = {}) => + new CalendarEventBuilder({ + bookerUrl: "https://cal.com/user/test-slug", + title: "Test Event", + startTime: mockStartTime, + endTime: mockEndTime, + type: "test-slug", + organizer: defaultOrganizer, + attendees: defaultAttendees, + ...overrides, + }); it("should create a basic calendar event", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - additionalNotes: "Some notes", - }) + const event = createBuilder({ additionalNotes: "Some notes" }) .withEventType({ - slug: "test-slug", description: "Test description", id: 123, }) @@ -60,15 +85,8 @@ describe("CalendarEventBuilder", () => { }); it("should create an event with event type details", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", description: "Test description", id: 123, hideCalendarNotes: true, @@ -87,73 +105,27 @@ describe("CalendarEventBuilder", () => { }); it("should create an event with organizer details", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const organizer = { + id: 456, + name: "John Doe", + email: "john@example.com", + username: "johndoe", + timeZone: "America/New_York", + language: { + translate: mockTranslate, + locale: "en", + }, + }; + + const event = createBuilder({ organizer }) .withEventType({ - slug: "test-slug", id: 123, }) - .withOrganizer({ - id: 456, - name: "John Doe", - email: "john@example.com", - username: "johndoe", - timeZone: "America/New_York", - language: { - translate: mockTranslate, - locale: "en", - }, - }) .build(); expect(event).not.toBeNull(); if (event) { - expect(event.organizer).toEqual({ - id: 456, - name: "John Doe", - email: "john@example.com", - username: "johndoe", - timeZone: "America/New_York", - language: { - translate: mockTranslate, - locale: "en", - }, - }); - } - }); - - it("should handle nameless organizer", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) - .withEventType({ - slug: "test-slug", - id: 123, - }) - .withOrganizer({ - id: 456, - name: null, - email: "john@example.com", - timeZone: "America/New_York", - language: { - translate: mockTranslate, - locale: "en", - }, - }) - .build(); - - expect(event).not.toBeNull(); - if (event) { - expect(event.organizer.name).toBe("Nameless"); + expect(event.organizer).toEqual(organizer); } }); @@ -179,18 +151,10 @@ describe("CalendarEventBuilder", () => { }, ]; - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder({ attendees, bookerUrl: "https://cal.com/user" }) .withEventType({ - slug: "test-slug", id: 123, }) - .withAttendees(attendees) .build(); expect(event).not.toBeNull(); @@ -211,15 +175,8 @@ describe("CalendarEventBuilder", () => { }; const userFieldsResponses = {}; - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withMetadataAndResponses({ @@ -240,15 +197,8 @@ describe("CalendarEventBuilder", () => { }); it("should create an event with location", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withLocation({ @@ -279,15 +229,8 @@ describe("CalendarEventBuilder", () => { domainWideDelegationCredentialId: null, }; - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withDestinationCalendar([destinationCalendar]) @@ -300,15 +243,8 @@ describe("CalendarEventBuilder", () => { }); it("should create an event with identifiers", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withIdentifiers({ @@ -325,15 +261,8 @@ describe("CalendarEventBuilder", () => { }); it("should create an event with confirmation settings", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withConfirmation({ @@ -350,15 +279,8 @@ describe("CalendarEventBuilder", () => { }); it("should set oneTimePassword to null when isConfirmedByDefault is true", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withConfirmation({ @@ -375,15 +297,8 @@ describe("CalendarEventBuilder", () => { }); it("should create an event with platform variables", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withPlatformVariables({ @@ -423,15 +338,8 @@ describe("CalendarEventBuilder", () => { }, ]; - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withAppsStatus(appsStatus) @@ -451,15 +359,8 @@ describe("CalendarEventBuilder", () => { password: "password123", }; - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withVideoCallData(videoCallData) @@ -488,15 +389,8 @@ describe("CalendarEventBuilder", () => { id: 101, }; - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withTeam(team) @@ -515,15 +409,8 @@ describe("CalendarEventBuilder", () => { interval: 1, }; - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withRecurring(recurringEvent) @@ -536,15 +423,8 @@ describe("CalendarEventBuilder", () => { }); it("should create an event with attendee seat ID", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withAttendeeSeatId("seat-123") @@ -557,15 +437,8 @@ describe("CalendarEventBuilder", () => { }); it("should create an event with UID", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withUid("booking-uid-123") @@ -578,15 +451,8 @@ describe("CalendarEventBuilder", () => { }); it("should create an event with one-time password", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withOneTimePassword("otp123") @@ -599,15 +465,8 @@ describe("CalendarEventBuilder", () => { }); it("should create an event with recurring event ID", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withRecurringEventId("recurring-123") @@ -622,15 +481,8 @@ describe("CalendarEventBuilder", () => { }); it("should create an event with assignment reason", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withAssignmentReason({ @@ -649,15 +501,8 @@ describe("CalendarEventBuilder", () => { }); it("should create an event with assignment reason without details", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withAssignmentReason({ @@ -676,15 +521,8 @@ describe("CalendarEventBuilder", () => { }); it("should create an event with null assignment reason", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) + const event = createBuilder() .withEventType({ - slug: "test-slug", id: 123, }) .withAssignmentReason(null) @@ -697,43 +535,43 @@ describe("CalendarEventBuilder", () => { }); it("should create a complete calendar event with all properties", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Complete Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - additionalNotes: "Complete test notes", - }) + const organizer = { + id: 456, + name: "John Doe", + email: "john@example.com", + username: "johndoe", + timeZone: "America/New_York", + language: { + translate: mockTranslate, + locale: "en", + }, + }; + + const attendees = [ + { + email: "attendee@example.com", + name: "Attendee", + timeZone: "Europe/London", + language: { + translate: mockTranslate, + locale: "en", + }, + }, + ]; + + const event = createBuilder({ + type: "complete-test", + organizer, + attendees, + title: "Complete Test Event", + additionalNotes: "Complete test notes", + }) .withEventType({ - slug: "complete-test", description: "Complete test description", id: 123, hideCalendarNotes: true, hideCalendarEventDetails: false, }) - .withOrganizer({ - id: 456, - name: "John Doe", - email: "john@example.com", - username: "johndoe", - timeZone: "America/New_York", - language: { - translate: mockTranslate, - locale: "en", - }, - }) - .withAttendees([ - { - email: "attendee@example.com", - name: "Attendee", - timeZone: "Europe/London", - language: { - translate: mockTranslate, - locale: "en", - }, - }, - ]) .withMetadataAndResponses({ customInputs: { question1: "answer1" }, responses: { @@ -826,11 +664,6 @@ describe("CalendarEventBuilder", () => { } }); - it("should return null when building without required fields", () => { - const builder = new CalendarEventBuilder(); - expect(builder.build()).toBeNull(); - }); - it("should create an event from an existing event", () => { const existingEvent = { title: "Existing Event", @@ -838,16 +671,11 @@ describe("CalendarEventBuilder", () => { endTime: mockEndTime, type: "existing-type", bookerUrl: "https://cal.com/user/test-slug", + organizer: defaultOrganizer, + attendees: defaultAttendees, }; - const event = CalendarEventBuilder.fromEvent(existingEvent) - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Updated Event", - startTime: mockStartTime, - endTime: mockEndTime, - }) - .build(); + const event = CalendarEventBuilder.fromEvent({ ...existingEvent, title: "Updated Event" }).build(); expect(event).not.toBeNull(); if (event) { @@ -857,16 +685,8 @@ describe("CalendarEventBuilder", () => { }); it("should propagate disableCancelling and disableRescheduling", () => { - const event = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: "https://cal.com/user/test-slug", - title: "Test Event", - startTime: mockStartTime, - endTime: mockEndTime, - additionalNotes: "Some notes", - }) + const event = createBuilder({ additionalNotes: "Some notes" }) .withEventType({ - slug: "test-slug", description: "Test description", id: 123, disableCancelling: true, @@ -1546,7 +1366,6 @@ describe("CalendarEventBuilder", () => { const eventFromBooking = await CalendarEventBuilder.fromBooking(mockBooking); const builtFromBooking = eventFromBooking.build(); - const manualBuilder = new CalendarEventBuilder(); const organizerPerson = { id: 8, name: "Match Host", @@ -1563,17 +1382,20 @@ describe("CalendarEventBuilder", () => { language: { translate: mockTranslate, locale: "en" }, }; + const manualBuilder = createBuilder({ + type: "match-event", + organizer: organizerPerson, + attendees: [attendeePerson], + bookerUrl: "https://cal.com", + title: "Match Test", + startTime: new Date(mockStartTime).toISOString(), + endTime: new Date(mockEndTime).toISOString(), + additionalNotes: "Match test description", + }); + const manualEvent = manualBuilder - .withBasicDetails({ - bookerUrl: "https://cal.com", - title: "Match Test", - startTime: new Date(mockStartTime).toISOString(), - endTime: new Date(mockEndTime).toISOString(), - additionalNotes: "Match test description", - }) .withEventType({ id: 700, - slug: "match-event", description: "Match event type", hideCalendarNotes: false, hideCalendarEventDetails: false, @@ -1586,8 +1408,6 @@ describe("CalendarEventBuilder", () => { disableRescheduling: false, disableCancelling: false, }) - .withOrganizer(organizerPerson) - .withAttendees([attendeePerson]) .withLocation({ location: "Test Location" }) .withIdentifiers({ iCalUID: "match-ical", iCalSequence: 1 }) .withConfirmation({ requiresConfirmation: false, isConfirmedByDefault: true }) diff --git a/packages/features/CalendarEventBuilder.ts b/packages/features/CalendarEventBuilder.ts index a419fb4ba8..7b5ee14671 100644 --- a/packages/features/CalendarEventBuilder.ts +++ b/packages/features/CalendarEventBuilder.ts @@ -9,26 +9,26 @@ import { } from "@calcom/features/eventtypes/di/EventTypeService.container"; import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; import { getTranslation } from "@calcom/i18n/server"; -import { getTimeFormatStringFromUserTimeFormat, type TimeFormat } from "@calcom/lib/timeFormat"; -import type { Attendee, BookingSeat, DestinationCalendar, Prisma, User } from "@calcom/prisma/client"; +import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; +import type { + Attendee, + BookingReference, + BookingSeat, + DestinationCalendar, + Prisma, + User, +} from "@calcom/prisma/client"; import type { SchedulingType } from "@calcom/prisma/enums"; import { bookingResponses as bookingResponsesSchema } from "@calcom/prisma/zod-utils"; import type { AppsStatus, CalEventResponses, CalendarEvent, Person } from "@calcom/types/Calendar"; import type { VideoCallData } from "@calcom/types/VideoApiAdapter"; import type { TFunction } from "i18next"; -const APP_TYPE_TO_NAME_MAP = new Map(ALL_APPS.map((app) => [app.type, app.name])); - -export type BookingForCalEventBuilder = NonNullable< - Awaited> +type CalendarEventRequiredFields = Required< + Pick >; -export type BookingMetaOptions = { - conferenceCredentialId?: number; - platformClientId?: string; - platformRescheduleUrl?: string; - platformCancelUrl?: string; - platformBookingUrl?: string; -}; +type CalendarEventBuilderInit = CalendarEventRequiredFields & Partial; +const APP_TYPE_TO_NAME_MAP = new Map(ALL_APPS.map((app) => [app.type, app.name])); async function _buildPersonFromUser( user: Pick @@ -65,14 +65,26 @@ async function _buildPersonFromAttendee( } satisfies Person; } -export class CalendarEventBuilder { - private event: Partial; +export type BuiltCalendarEvent = Omit & { bookerUrl: string }; +export type BookingForCalEventBuilder = NonNullable< + Awaited> +>; +export type BookingMetaOptions = { + conferenceCredentialId?: number; + platformClientId?: string; + platformRescheduleUrl?: string; + platformCancelUrl?: string; + platformBookingUrl?: string; +}; - constructor(existingEvent?: Partial) { - this.event = existingEvent || {}; +export class CalendarEventBuilder { + private event: CalendarEventBuilderInit; + + constructor(existingEvent: CalendarEventBuilderInit) { + this.event = existingEvent; } - static fromEvent(event: Partial) { + static fromEvent(event: CalendarEventBuilderInit) { return new CalendarEventBuilder(event); } @@ -88,7 +100,6 @@ export class CalendarEventBuilder { if (!user) throw new Error(`Booking ${uid} is missing an organizer — user may have been deleted.`); if (!eventType) throw new Error(`Booking ${uid} is missing eventType — it may have been deleted.`); - const builder = new CalendarEventBuilder(); const { description, attendees, @@ -146,18 +157,21 @@ export class CalendarEventBuilder { const recurring = parseRecurringEvent(eventType.recurringEvent) ?? undefined; + const builder = new CalendarEventBuilder({ + bookerUrl, + title, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + type: eventType.slug, + organizer: organizerPerson, + attendees: attendeesList, + additionalNotes, + }); + // Base builder setup builder - .withBasicDetails({ - bookerUrl, - title, - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - additionalNotes, - }) .withEventType({ id: eventType.id, - slug: eventType.slug, description: eventType.description, hideCalendarNotes: eventType.hideCalendarNotes, hideCalendarEventDetails: eventType.hideCalendarEventDetails, @@ -170,8 +184,6 @@ export class CalendarEventBuilder { disableRescheduling: eventType.disableRescheduling ?? false, disableCancelling: eventType.disableCancelling ?? false, }) - .withOrganizer(organizerPerson) - .withAttendees(attendeesList) .withMetadataAndResponses({ additionalNotes, customInputs: parsedCustomInputs, @@ -227,7 +239,7 @@ export class CalendarEventBuilder { } // Video - if (videoCallData && videoCallData.url) { + if (videoCallData?.url) { builder.withVideoCallData({ ...videoCallData, id: videoCallData.id ?? "", @@ -237,7 +249,7 @@ export class CalendarEventBuilder { } references - .filter((r) => r && r.type) + .filter((r) => r?.type) .forEach((ref) => { const appName = APP_TYPE_TO_NAME_MAP.get(ref.type) || ref.type.replace("_", "-"); appsStatus.push({ @@ -289,32 +301,7 @@ export class CalendarEventBuilder { return builder; } - withBasicDetails({ - bookerUrl, - title, - startTime, - endTime, - additionalNotes, - }: { - bookerUrl: string; - title: string; - startTime: string; - endTime: string; - additionalNotes?: string; - }) { - this.event = { - ...this.event, - bookerUrl, - title, - startTime, - endTime, - additionalNotes, - }; - return this; - } - withEventType(eventType: { - slug: string; description?: string | null; id: number; hideCalendarNotes?: boolean; @@ -330,7 +317,6 @@ export class CalendarEventBuilder { }) { this.event = { ...this.event, - type: eventType.slug, description: eventType.description, eventTypeId: eventType.id, hideCalendarNotes: eventType.hideCalendarNotes, @@ -348,43 +334,6 @@ export class CalendarEventBuilder { return this; } - withOrganizer(organizer: { - id: number; - name: string | null; - email: string; - username?: string; - usernameInOrg?: string; - timeZone: string; - timeFormat?: TimeFormat; - language: { - translate: TFunction; - locale: string; - }; - }) { - this.event = { - ...this.event, - organizer: { - id: organizer.id, - name: organizer.name || "Nameless", - email: organizer.email, - username: organizer.username, - usernameInOrg: organizer.usernameInOrg, - timeZone: organizer.timeZone, - language: organizer.language, - timeFormat: organizer.timeFormat, - }, - }; - return this; - } - - withAttendees(attendees: Person[]) { - this.event = { - ...this.event, - attendees, - }; - return this; - } - withMetadataAndResponses({ additionalNotes, customInputs, @@ -491,6 +440,9 @@ export class CalendarEventBuilder { } withTeam(team?: { name: string; members: Person[]; id: number }) { + if (!team) { + return this; + } this.event = { ...this.event, team, @@ -522,7 +474,10 @@ export class CalendarEventBuilder { return this; } - withRecurringEventId(recurringEventId: string) { + withRecurringEventId(recurringEventId?: string | null) { + if (!recurringEventId) { + return this; + } this.event = { ...this.event, existingRecurringEvent: { @@ -572,18 +527,24 @@ export class CalendarEventBuilder { return this; } - build(): CalendarEvent | null { - // Validate required fields - if ( - !this.event.startTime || - !this.event.endTime || - !this.event.type || - !this.event.bookerUrl || - !this.event.title - ) { - return null; - } + withVideoCallDataFromReferences(bookingReferences: BookingReference[]): this { + const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video")); - return this.event as CalendarEvent; + if (videoCallReference) { + this.event = { + ...this.event, + videoCallData: { + type: videoCallReference.type, + id: videoCallReference.meetingId, + password: videoCallReference?.meetingPassword, + url: videoCallReference.meetingUrl, + }, + }; + } + return this; + } + + build(): BuiltCalendarEvent { + return this.event; } } diff --git a/packages/features/bookings/lib/handleNewBooking/addVideoCallDataToEvent.ts b/packages/features/bookings/lib/handleNewBooking/addVideoCallDataToEvent.ts deleted file mode 100644 index 624ce91a3e..0000000000 --- a/packages/features/bookings/lib/handleNewBooking/addVideoCallDataToEvent.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { BookingReference } from "@calcom/prisma/client"; -import type { CalendarEvent } from "@calcom/types/Calendar"; - -/** Updates the evt object with video call data found from booking references - * - * @param bookingReferences - * @param evt - * - * @returns updated evt with video call data - */ -export const addVideoCallDataToEvent = (bookingReferences: BookingReference[], evt: CalendarEvent) => { - const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video")); - - if (videoCallReference) { - evt.videoCallData = { - type: videoCallReference.type, - id: videoCallReference.meetingId, - password: videoCallReference?.meetingPassword, - url: videoCallReference.meetingUrl, - }; - } - - return evt; -}; diff --git a/packages/features/bookings/lib/handleNewBooking/test/buildEventForTeamEventType.test.ts b/packages/features/bookings/lib/handleNewBooking/test/buildEventForTeamEventType.test.ts deleted file mode 100644 index f15b01fe03..0000000000 --- a/packages/features/bookings/lib/handleNewBooking/test/buildEventForTeamEventType.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -// or wherever it's from - -import { SchedulingType } from "@calcom/prisma/enums"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { buildEventForTeamEventType } from "../../service/RegularBookingService"; - -vi.mock("@calcom/i18n/server", () => ({ - getTranslation: vi.fn().mockResolvedValue("translated"), -})); - -vi.mock("@calcom/prisma", () => { - return { - default: vi.fn(), - prisma: {}, - }; -}); - -const withTeamSpy = vi.fn().mockReturnThis(); -const withDestinationCalendarSpy = vi.fn().mockReturnThis(); - -vi.mock("@calcom/features/CalendarEventBuilder", () => { - return { - CalendarEventBuilder: { - fromEvent: vi.fn().mockImplementation((_evt) => ({ - withDestinationCalendar: withDestinationCalendarSpy, - withTeam: withTeamSpy, - build: vi.fn().mockImplementation(function () { - return { - destinationCalendar: [], - team: {}, // <- you won't use this result anyway - }; - }), - })), - }, - }; -}); - -vi.mock("@calcom/app-store/_utils/calendars/processExternalId", () => ({ - default: vi.fn((dc) => `external-${dc?.externalId ?? "id"}`), -})); - -vi.mock("@calcom/app-store/delegationCredential", () => ({ - enrichHostsWithDelegationCredentials: vi.fn(), - getUsersCredentialsIncludeServiceAccountKey: vi.fn(), - getCredentialForSelectedCalendar: vi.fn(), -})); - -vi.mock("@calcom/features/abuse-scoring/lib/hooks", () => ({ - onEventTypeChange: vi.fn(), - onSignup: vi.fn(), - onBookingCreated: vi.fn(), -})); - -vi.mock("@calcom/features/di/watchlist/containers/SpamCheckService.container", () => ({ - getSpamCheckService: vi.fn().mockReturnValue({ - checkForSpam: vi.fn().mockResolvedValue({ isSpam: false }), - }), -})); - -vi.mock("@calcom/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain", () => ({ - checkIfFreeEmailDomain: vi.fn().mockResolvedValue(false), -})); - -const baseUser = (overrides: Record = {}) => ({ - id: 1, - name: "Alice", - email: "alice@example.com", - timeZone: "Europe/Paris", - locale: "fr", - destinationCalendar: { - id: 123, - integration: "google", - externalId: "ext-123", - primaryEmail: "alice@example.com", - userId: 1, - eventTypeId: null, - credentialId: null, - delegationCredentialId: null, - domainWideDelegationCredentialId: null, - }, - isFixed: true, - ...overrides, -}); - -describe("buildEventForTeamEventType", () => { - it("throws if schedulingType is null", async () => { - await expect( - buildEventForTeamEventType({ - existingEvent: {}, - users: [], - organizerUser: { email: "organizer@example.com" }, - schedulingType: null, - }) - ).rejects.toThrow("Scheduling type is required for team event type"); - }); - - it("filters out the organizer", async () => { - await buildEventForTeamEventType({ - existingEvent: {}, - users: [baseUser({ email: "organizer@example.com" })], - organizerUser: { email: "organizer@example.com" }, - schedulingType: SchedulingType.COLLECTIVE, - }); - - const teamArgs = withTeamSpy.mock.calls[0][0]; - const memberEmails = teamArgs.members.map((m: { email: string }) => m.email); - - expect(memberEmails).not.toContain("organizer@example.com"); - }); - - it("includes destinationCalendars for COLLECTIVE", async () => { - await buildEventForTeamEventType({ - existingEvent: { destinationCalendar: [] }, - users: [baseUser({ id: 2 })], - organizerUser: { email: "organizer@example.com" }, - schedulingType: SchedulingType.COLLECTIVE, - }); - - const withDestinationCalendarArgs = withDestinationCalendarSpy.mock.calls[0][0]; - - expect(withDestinationCalendarArgs).not.toHaveLength(0); - }); - - it("does not include destinationCalendars for ROUND_ROBIN", async () => { - await buildEventForTeamEventType({ - existingEvent: { destinationCalendar: [] }, - users: [baseUser({ id: 2 })], - organizerUser: { email: "organizer@example.com" }, - schedulingType: SchedulingType.ROUND_ROBIN, - }); - - const withDestinationCalendarArgs = withDestinationCalendarSpy.mock.calls[0][0]; - - expect(withDestinationCalendarArgs).toHaveLength(0); - }); - - it("includes one non-fixed user for ROUND_ROBIN when fixed users exist", async () => { - await buildEventForTeamEventType({ - existingEvent: {}, - users: [ - baseUser({ id: 2, isFixed: false, email: "nonfixed@example.com" }), - baseUser({ id: 3, isFixed: true, email: "fixed@example.com" }), - ], - organizerUser: { email: "organizer@example.com" }, - schedulingType: SchedulingType.ROUND_ROBIN, - }); - - const teamArgs = withTeamSpy.mock.calls[0][0]; - const memberEmails = teamArgs.members.map((m: { email: string }) => m.email); - - expect(memberEmails).toContain("fixed@example.com"); - expect(memberEmails).toContain("nonfixed@example.com"); - }); - - it("includes only the first non-fixed user for ROUND_ROBIN when multiple exist", async () => { - await buildEventForTeamEventType({ - existingEvent: {}, - users: [ - baseUser({ id: 3, isFixed: true, email: "fixed@example.com" }), - baseUser({ id: 2, isFixed: false, email: "nonfixed1@example.com" }), - baseUser({ id: 4, isFixed: false, email: "nonfixed2@example.com" }), - ], - organizerUser: { email: "organizer@example.com" }, - schedulingType: SchedulingType.ROUND_ROBIN, - }); - - const teamArgs = withTeamSpy.mock.calls[0][0]; - const memberEmails = teamArgs.members.map((m: { email: string }) => m.email); - - expect(memberEmails).toContain("fixed@example.com"); - expect(memberEmails).toContain("nonfixed1@example.com"); - expect(memberEmails).toContain("nonfixed2@example.com"); - }); - - it("builds a team with fallback name and id", async () => { - await buildEventForTeamEventType({ - existingEvent: {}, - users: [baseUser()], - organizerUser: { email: "organizer@example.com" }, - schedulingType: SchedulingType.COLLECTIVE, - team: null, - }); - - // now inspect what was passed into withTeam() - const teamArgs = withTeamSpy.mock.calls[0][0]; - - expect(teamArgs.name).toBe("Nameless"); - expect(teamArgs.id).toBe(0); - }); - - beforeEach(() => { - vi.clearAllMocks(); - }); -}); diff --git a/packages/features/bookings/lib/handleNewBooking/test/computeTeamData.test.ts b/packages/features/bookings/lib/handleNewBooking/test/computeTeamData.test.ts new file mode 100644 index 0000000000..0648f55e73 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/computeTeamData.test.ts @@ -0,0 +1,176 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +import { SchedulingType } from "@calcom/prisma/enums"; + +import { computeTeamData } from "../../service/RegularBookingService"; + +vi.mock("@calcom/i18n/server", () => ({ + getTranslation: vi.fn().mockResolvedValue((key: string) => key), +})); + +vi.mock("@calcom/prisma", () => { + return { + default: vi.fn(), + prisma: {}, + }; +}); + +vi.mock("@calcom/app-store/_utils/calendars/processExternalId", () => ({ + default: vi.fn((dc) => `external-${dc?.externalId ?? "id"}`), +})); + +const baseUser = (overrides: Record = {}) => ({ + id: 1, + name: "Alice", + email: "alice@example.com", + timeZone: "Europe/Paris", + locale: "fr", + destinationCalendar: { + id: 123, + integration: "google", + externalId: "ext-123", + primaryEmail: "alice@example.com", + userId: 1, + eventTypeId: null, + credentialId: null, + delegationCredentialId: null, + domainWideDelegationCredentialId: null, + }, + isFixed: true, + ...overrides, +}); + +describe("computeTeamData", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns empty arrays if isTeamEventType is false", async () => { + const result = await computeTeamData({ + isTeamEventType: false, + schedulingType: SchedulingType.COLLECTIVE, + users: [baseUser()], + organizerEmail: "organizer@example.com", + }); + + expect(result.teamMembers).toHaveLength(0); + expect(result.teamDestinationCalendars).toHaveLength(0); + }); + + it("returns empty arrays if schedulingType is null", async () => { + const result = await computeTeamData({ + isTeamEventType: true, + schedulingType: null, + users: [baseUser()], + organizerEmail: "organizer@example.com", + }); + + expect(result.teamMembers).toHaveLength(0); + expect(result.teamDestinationCalendars).toHaveLength(0); + }); + + it("filters out the organizer from team members", async () => { + const result = await computeTeamData({ + isTeamEventType: true, + schedulingType: SchedulingType.COLLECTIVE, + users: [baseUser({ email: "organizer@example.com" }), baseUser({ id: 2, email: "member@example.com" })], + organizerEmail: "organizer@example.com", + }); + + const memberEmails = result.teamMembers.map((m) => m.email); + expect(memberEmails).not.toContain("organizer@example.com"); + expect(memberEmails).toContain("member@example.com"); + }); + + it("includes destinationCalendars for COLLECTIVE scheduling type", async () => { + const result = await computeTeamData({ + isTeamEventType: true, + schedulingType: SchedulingType.COLLECTIVE, + users: [baseUser({ id: 2, email: "member@example.com" })], + organizerEmail: "organizer@example.com", + }); + + expect(result.teamDestinationCalendars).toHaveLength(1); + expect(result.teamDestinationCalendars[0].externalId).toBe("external-ext-123"); + }); + + it("does not include destinationCalendars for ROUND_ROBIN scheduling type", async () => { + const result = await computeTeamData({ + isTeamEventType: true, + schedulingType: SchedulingType.ROUND_ROBIN, + users: [baseUser({ id: 2, email: "member@example.com" })], + organizerEmail: "organizer@example.com", + }); + + expect(result.teamDestinationCalendars).toHaveLength(0); + }); + + it("includes both fixed and non-fixed users for ROUND_ROBIN", async () => { + const result = await computeTeamData({ + isTeamEventType: true, + schedulingType: SchedulingType.ROUND_ROBIN, + users: [ + baseUser({ id: 2, isFixed: false, email: "nonfixed@example.com" }), + baseUser({ id: 3, isFixed: true, email: "fixed@example.com" }), + ], + organizerEmail: "organizer@example.com", + }); + + const memberEmails = result.teamMembers.map((m) => m.email); + expect(memberEmails).toContain("fixed@example.com"); + expect(memberEmails).toContain("nonfixed@example.com"); + }); + + it("includes all non-fixed users for ROUND_ROBIN when multiple exist", async () => { + const result = await computeTeamData({ + isTeamEventType: true, + schedulingType: SchedulingType.ROUND_ROBIN, + users: [ + baseUser({ id: 3, isFixed: true, email: "fixed@example.com" }), + baseUser({ id: 2, isFixed: false, email: "nonfixed1@example.com" }), + baseUser({ id: 4, isFixed: false, email: "nonfixed2@example.com" }), + ], + organizerEmail: "organizer@example.com", + }); + + const memberEmails = result.teamMembers.map((m) => m.email); + expect(memberEmails).toContain("fixed@example.com"); + expect(memberEmails).toContain("nonfixed1@example.com"); + expect(memberEmails).toContain("nonfixed2@example.com"); + }); + + it("returns team members with correct structure", async () => { + const result = await computeTeamData({ + isTeamEventType: true, + schedulingType: SchedulingType.COLLECTIVE, + users: [baseUser({ id: 2, email: "member@example.com", name: "Member Name" })], + organizerEmail: "organizer@example.com", + }); + + expect(result.teamMembers).toHaveLength(1); + expect(result.teamMembers[0]).toEqual({ + id: 2, + email: "member@example.com", + name: "Member Name", + firstName: "", + lastName: "", + timeZone: "Europe/Paris", + language: { + translate: expect.any(Function), + locale: "fr", + }, + }); + }); + + it("handles users without destinationCalendar for COLLECTIVE", async () => { + const result = await computeTeamData({ + isTeamEventType: true, + schedulingType: SchedulingType.COLLECTIVE, + users: [baseUser({ id: 2, email: "member@example.com", destinationCalendar: null })], + organizerEmail: "organizer@example.com", + }); + + expect(result.teamMembers).toHaveLength(1); + expect(result.teamDestinationCalendars).toHaveLength(0); + }); +}); diff --git a/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts index 0a0d4319d8..86c6782f2d 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts @@ -1,8 +1,8 @@ import { cloneDeep } from "lodash"; import { sendRescheduledSeatEmailAndSMS } from "@calcom/emails/email-manager"; +import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder"; import type EventManager from "@calcom/features/bookings/lib/EventManager"; -import { addVideoCallDataToEvent } from "@calcom/features/bookings/lib/handleNewBooking/addVideoCallDataToEvent"; import { getTranslation } from "@calcom/i18n/server"; import prisma from "@calcom/prisma"; import type { Person, CalendarEvent } from "@calcom/types/Calendar"; @@ -57,7 +57,7 @@ const attendeeRescheduleSeatedBooking = async ( originalRescheduledBooking = null; const evtWithVideoCallData = originalBookingReferences - ? addVideoCallDataToEvent(originalBookingReferences, evt) + ? CalendarEventBuilder.fromEvent(evt).withVideoCallDataFromReferences(originalBookingReferences).build() : evt; await sendRescheduledSeatEmailAndSMS(evtWithVideoCallData, seatAttendee as Person, eventType.metadata); @@ -103,7 +103,7 @@ const attendeeRescheduleSeatedBooking = async ( await eventManager.updateCalendarAttendees(copyEvent, newTimeSlotBooking); const copyEventWithVideoCallData = newTimeSlotBooking.references - ? addVideoCallDataToEvent(newTimeSlotBooking.references, copyEvent) + ? CalendarEventBuilder.fromEvent(copyEvent).withVideoCallDataFromReferences(newTimeSlotBooking.references).build() : copyEvent; await sendRescheduledSeatEmailAndSMS( diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts index f393c0fb11..170a490a07 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts @@ -8,7 +8,7 @@ import { HttpError } from "@calcom/lib/http-error"; import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; -import { addVideoCallDataToEvent } from "../../../handleNewBooking/addVideoCallDataToEvent"; +import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder"; import { findBookingQuery } from "../../../handleNewBooking/findBookingQuery"; import type { createLoggerWithEventDetails } from "../../../handleNewBooking/logger"; import type { SeatedBooking, RescheduleSeatedBookingObject, NewTimeSlotBooking } from "../../types"; @@ -118,7 +118,9 @@ const combineTwoSeatedBookings = async ( evt.attendees = updatedBookingAttendees; - evt = { ...addVideoCallDataToEvent(updatedNewBooking.references, evt), bookerUrl: evt.bookerUrl }; + evt = CalendarEventBuilder.fromEvent(evt) + .withVideoCallDataFromReferences(updatedNewBooking.references) + .build(); const copyEvent = cloneDeep(evt); diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts index 69e8e6b8c3..06b1419622 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts @@ -5,7 +5,7 @@ import type EventManager from "@calcom/features/bookings/lib/EventManager"; import prisma from "@calcom/prisma"; import type { AdditionalInformation, AppsStatus } from "@calcom/types/Calendar"; -import { addVideoCallDataToEvent } from "../../../handleNewBooking/addVideoCallDataToEvent"; +import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder"; import type { Booking } from "../../../handleNewBooking/createBooking"; import { findBookingQuery } from "../../../handleNewBooking/findBookingQuery"; import { handleAppsStatus } from "../../../handleNewBooking/handleAppsStatus"; @@ -67,7 +67,9 @@ const moveSeatedBookingToNewTimeSlot = async ( cancellationReason: rescheduleReason, }); - evt = { ...addVideoCallDataToEvent(newBooking.references, evt), bookerUrl: evt.bookerUrl }; + evt = CalendarEventBuilder.fromEvent(evt) + .withVideoCallDataFromReferences(newBooking.references) + .build(); const copyEvent = cloneDeep(evt); diff --git a/packages/features/bookings/lib/handleSeats/types.d.ts b/packages/features/bookings/lib/handleSeats/types.d.ts index 8c13bb5fc5..c51ac878c1 100644 --- a/packages/features/bookings/lib/handleSeats/types.d.ts +++ b/packages/features/bookings/lib/handleSeats/types.d.ts @@ -3,6 +3,7 @@ import type z from "zod"; import type { Workflow } from "@calcom/features/ee/workflows/lib/types"; import type { TraceContext } from "@calcom/lib/tracing"; import type { Prisma } from "@calcom/prisma/client"; +import type { BuiltCalendarEvent } from "@calcom/features/CalendarEventBuilder"; import type { AppsStatus, CalendarEvent } from "@calcom/types/Calendar"; import type { BookingEventHandlerService } from "../../onBookingEvents/BookingEventHandlerService"; @@ -29,9 +30,7 @@ export type NewSeatedBookingObject = { rescheduleUid: string | undefined; reqBookingUid: string | undefined; eventType: NewBookingEventType; - evt: Omit & { - bookerUrl: string; - }; + evt: BuiltCalendarEvent; invitee: Invitee; allCredentials: Awaited>; organizerUser: OrganizerUser; diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 33a66db6f3..f34a38e88d 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -36,6 +36,7 @@ import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEvent import type { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService"; import type { BookingRescheduledPayload } from "@calcom/features/bookings/lib/onBookingEvents/types.d"; import type { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTasker"; +import type { BuiltCalendarEvent } from "@calcom/features/CalendarEventBuilder"; import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder"; import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container"; import { CreditService } from "@calcom/features/ee/billing/credit-service"; @@ -111,7 +112,6 @@ import { getAllCredentialsIncludeServiceAccountKey } from "../getAllCredentialsF import { refreshCredentials } from "../getAllCredentialsForUsersOnEvent/refreshCredentials"; import getBookingDataSchema from "../getBookingDataSchema"; import type { LuckyUserService } from "../getLuckyUser"; -import { addVideoCallDataToEvent } from "../handleNewBooking/addVideoCallDataToEvent"; import { buildBookingCreatedAuditData, buildBookingRescheduledAuditData, @@ -285,29 +285,34 @@ const buildDryRunEventManager = () => { }; }; -export const buildEventForTeamEventType = async ({ - existingEvent: evt, - users, - organizerUser, +type TeamMember = { + id: number; + email: string; + name: string; + firstName: string; + lastName: string; + timeZone: string; + language: { translate: Awaited>; locale: string }; +}; + +export async function computeTeamData({ + isTeamEventType, schedulingType, - team, + users, + organizerEmail, }: { - existingEvent: Partial; + isTeamEventType: boolean; + schedulingType: SchedulingType | null; users: (Pick & { destinationCalendar: DestinationCalendar | null; isFixed?: boolean; })[]; - organizerUser: { email: string }; - schedulingType: SchedulingType | null; - team?: { - id: number; - name: string; - } | null; -}) => { - // not null assertion. - if (!schedulingType) { - throw new Error("Scheduling type is required for team event type"); + organizerEmail: string; +}): Promise<{ teamMembers: TeamMember[]; teamDestinationCalendars: DestinationCalendar[] }> { + if (!isTeamEventType || !schedulingType) { + return { teamMembers: [], teamDestinationCalendars: [] }; } + const teamDestinationCalendars: DestinationCalendar[] = []; const fixedUsers = users.filter((user) => user.isFixed); const nonFixedUsers = users.filter((user) => !user.isFixed); @@ -316,9 +321,8 @@ export const buildEventForTeamEventType = async ({ // Organizer or user owner of this event type it's not listed as a team member. const teamMemberPromises = filteredUsers - .filter((user) => user.email !== organizerUser.email) + .filter((user) => user.email !== organizerEmail) .map(async (user) => { - // TODO: Add back once EventManager tests are ready https://github.com/calcom/cal.com/pull/14610#discussion_r1567817120 // push to teamDestinationCalendars if it's a team event but collective only if (schedulingType === "COLLECTIVE" && user.destinationCalendar) { teamDestinationCalendars.push({ @@ -326,7 +330,6 @@ export const buildEventForTeamEventType = async ({ externalId: processExternalId(user.destinationCalendar), }); } - return { id: user.id, email: user.email ?? "", @@ -342,37 +345,8 @@ export const buildEventForTeamEventType = async ({ }); const teamMembers = await Promise.all(teamMemberPromises); - - const updatedEvt = CalendarEventBuilder.fromEvent(evt) - ?.withDestinationCalendar([...(evt.destinationCalendar ?? []), ...teamDestinationCalendars]) - .build(); - - if (!updatedEvt) { - throw new HttpError({ - statusCode: 400, - message: "Failed to build event with destination calendar due to missing required fields", - }); - } - - evt = updatedEvt; - - const teamEvt = CalendarEventBuilder.fromEvent(evt) - ?.withTeam({ - members: teamMembers, - name: team?.name || "Nameless", - id: team?.id ?? 0, - }) - .build(); - - if (!teamEvt) { - throw new HttpError({ - statusCode: 400, - message: "Failed to build team event due to missing required fields", - }); - } - - return teamEvt; -}; + return { teamMembers, teamDestinationCalendars }; +} function buildTroubleshooterData({ eventType, @@ -1520,18 +1494,43 @@ async function handler( optionValue: "", }; + // Only attach recurring config when this booking belongs to a recurring series. + const computedRecurringEvent = + reqBody.recurringEventId && eventType.recurringEvent + ? { ...eventType.recurringEvent, count: recurringCount ?? eventType.recurringEvent.count } + : undefined; + + const { teamMembers, teamDestinationCalendars } = await computeTeamData({ + isTeamEventType, + schedulingType: eventType.schedulingType, + users, + organizerEmail: organizerUser.email, + }); + + const teamInfo = eventType.team; + const eventName = getEventName(eventNameObject); - const builtEvt = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl, - title: eventName, - startTime: dayjs(reqBody.start).utc().format(), - endTime: dayjs(reqBody.end).utc().format(), - additionalNotes, - }) + let evt: BuiltCalendarEvent = new CalendarEventBuilder({ + bookerUrl, + title: eventName, + startTime: dayjs(reqBody.start).utc().format(), + endTime: dayjs(reqBody.end).utc().format(), + type: eventType.slug, + organizer: { + id: organizerUser.id, + name: organizerUser.name || "Nameless", + email: organizerEmail, + username: organizerUser.username || undefined, + usernameInOrg: organizerOrganizationProfile?.username || undefined, + timeZone: organizerUser.timeZone, + language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" }, + timeFormat: getTimeFormatStringFromUserTimeFormat(organizerUser.timeFormat), + }, + attendees: attendeesList, + additionalNotes, + }) .withEventType({ - slug: eventType.slug, description: eventType.description, id: eventType.id, hideCalendarNotes: eventType.hideCalendarNotes, @@ -1546,17 +1545,6 @@ async function handler( disableRescheduling: eventType.disableRescheduling ?? false, disableCancelling: eventType.disableCancelling ?? false, }) - .withOrganizer({ - id: organizerUser.id, - name: organizerUser.name || "Nameless", - email: organizerEmail, - username: organizerUser.username || undefined, - usernameInOrg: organizerOrganizationProfile?.username || undefined, - timeZone: organizerUser.timeZone, - language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" }, - timeFormat: getTimeFormatStringFromUserTimeFormat(organizerUser.timeFormat), - }) - .withAttendees(attendeesList) .withMetadataAndResponses({ additionalNotes, customInputs, @@ -1567,7 +1555,11 @@ async function handler( location: platformBookingLocation ?? bookingLocation, // Will be processed by the EventManager later. conferenceCredentialId, }) - .withDestinationCalendar(destinationCalendar) + .withDestinationCalendar( + teamDestinationCalendars.length > 0 + ? [...(destinationCalendar ?? []), ...teamDestinationCalendars] + : destinationCalendar + ) .withIdentifiers({ iCalUID, iCalSequence }) .withConfirmation({ requiresConfirmation: !isConfirmedByDefault, @@ -1581,6 +1573,17 @@ async function handler( }) .withOrganization(organizerOrganizationId) .withHashedLink(hasHashedBookingLink ? (reqBody.hashedLink ?? null) : null) + .withRecurring(computedRecurringEvent ?? undefined) + .withRecurringEventId(input.bookingData.thirdPartyRecurringEventId) + .withTeam( + isTeamEventType + ? { + members: teamMembers, + name: teamInfo?.name || "Nameless", + id: teamInfo?.id ?? 0, + } + : undefined + ) .withHideBranding( await getEventTypeService().shouldHideBrandingForEventType(eventType.id, { team: eventType.team @@ -1597,46 +1600,6 @@ async function handler( ) .build(); - if (!builtEvt) { - throw new HttpError({ - statusCode: 400, - message: "Failed to build calendar event due to missing required fields", - }); - } - - let evt: CalendarEvent = builtEvt; - - if (input.bookingData.thirdPartyRecurringEventId) { - const updatedEvt = CalendarEventBuilder.fromEvent(evt) - ?.withRecurringEventId(input.bookingData.thirdPartyRecurringEventId) - .build(); - - if (!updatedEvt) { - throw new HttpError({ - statusCode: 400, - message: "Failed to build event with recurring event ID due to missing required fields", - }); - } - - evt = updatedEvt; - } - - if (isTeamEventType) { - const teamEvt = await buildEventForTeamEventType({ - existingEvent: evt, - schedulingType: eventType.schedulingType, - users, - team: eventType.team, - organizerUser, - }); - - if (!teamEvt) { - throw new HttpError({ statusCode: 400, message: "Failed to build team event" }); - } - - evt = teamEvt; - } - // data needed for triggering webhooks const eventTypeInfo: EventTypeInfo = { eventTitle: eventType.title, @@ -1799,7 +1762,7 @@ async function handler( rescheduleUid, reqBookingUid: reqBody.bookingUid, eventType, - evt: { ...evt, seatsPerTimeSlot: eventType.seatsPerTimeSlot, bookerUrl }, + evt, invitee, allCredentials, organizerUser, @@ -1874,24 +1837,10 @@ async function handler( }) .build(); - if (!updatedEvt) { - throw new HttpError({ - statusCode: 400, - message: "Failed to build event with new identifiers due to missing required fields", - }); - } - evt = updatedEvt; } } - if (reqBody.recurringEventId && eventType.recurringEvent) { - // Overriding the recurring event configuration count to be the actual number of events booked for - // the recurring event (equal or less than recurring event configuration count) - eventType.recurringEvent = Object.assign({}, eventType.recurringEvent, { count: recurringCount }); - evt.recurringEvent = eventType.recurringEvent; - } - const changedOrganizer = !!originalRescheduledBooking && (eventType.schedulingType === SchedulingType.ROUND_ROBIN || @@ -2002,44 +1951,22 @@ async function handler( } } - const updatedEvtWithUid = CalendarEventBuilder.fromEvent(evt) - ?.withUid(booking.uid ?? null) + evt = CalendarEventBuilder.fromEvent(evt) + .withUid(booking.uid ?? null) .build(); - if (!updatedEvtWithUid) { - throw new HttpError({ - statusCode: 400, - message: "Failed to build event with UID due to missing required fields", - }); - } - - evt = updatedEvtWithUid; - - const updatedEvtWithPassword = CalendarEventBuilder.fromEvent(evt) - ?.withOneTimePassword(booking.oneTimePassword ?? null) + evt = CalendarEventBuilder.fromEvent(evt) + .withOneTimePassword(booking.oneTimePassword ?? null) .build(); - if (!updatedEvtWithPassword) { - throw new HttpError({ - statusCode: 400, - message: "Failed to build event with one-time password due to missing required fields", - }); - } - - evt = updatedEvtWithPassword; - // Add assignment reason to evt for emails if (assignmentReason) { - const updatedEvtWithAssignmentReason = CalendarEventBuilder.fromEvent(evt) - ?.withAssignmentReason({ + evt = CalendarEventBuilder.fromEvent(evt) + .withAssignmentReason({ category: getAssignmentReasonCategory(assignmentReason.reasonEnum), details: assignmentReason.reasonString ?? null, }) .build(); - - if (updatedEvtWithAssignmentReason) { - evt = updatedEvtWithAssignmentReason; - } } if (booking && booking.id && eventType.seatsPerTimeSlot) { @@ -2121,7 +2048,9 @@ async function handler( // cancel workflow reminders from previous rescheduled booking await WorkflowRepository.deleteAllWorkflowReminders(originalRescheduledBooking.workflowReminders); - evt = addVideoCallDataToEvent(originalRescheduledBooking.references, evt); + evt = CalendarEventBuilder.fromEvent(evt) + .withVideoCallDataFromReferences(originalRescheduledBooking.references) + .build(); evt.rescheduledBy = reqBody.rescheduledBy; // If organizer is changed in RR event then we need to delete the previous host destination calendar events diff --git a/packages/features/ee/managed-event-types/reassignment/services/ManagedEventManualReassignmentService.ts b/packages/features/ee/managed-event-types/reassignment/services/ManagedEventManualReassignmentService.ts index 5546366140..cc0f1b5a25 100644 --- a/packages/features/ee/managed-event-types/reassignment/services/ManagedEventManualReassignmentService.ts +++ b/packages/features/ee/managed-event-types/reassignment/services/ManagedEventManualReassignmentService.ts @@ -485,29 +485,28 @@ export class ManagedEventManualReassignmentService { }) ); - const builder = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl, - title: newBooking.title, - startTime: dayjs(newBooking.startTime).utc().format(), - endTime: dayjs(newBooking.endTime).utc().format(), - additionalNotes: newBooking.description || undefined, - }) + const builder = new CalendarEventBuilder({ + bookerUrl, + title: newBooking.title, + startTime: dayjs(newBooking.startTime).utc().format(), + endTime: dayjs(newBooking.endTime).utc().format(), + type: targetEventTypeDetails.slug, + organizer: { + id: newUser.id, + name: newUser.name || "Nameless", + email: newUser.email, + timeZone: newUser.timeZone, + language: { translate: newUserT, locale: newUser.locale ?? "en" }, + }, + attendees, + additionalNotes: newBooking.description || undefined, + }) .withEventType({ id: targetEventTypeDetails.id, - slug: targetEventTypeDetails.slug, description: newBooking.description, hideOrganizerEmail: targetEventTypeDetails.hideOrganizerEmail, schedulingType: targetEventTypeDetails.schedulingType, }) - .withOrganizer({ - id: newUser.id, - name: newUser.name, - email: newUser.email, - timeZone: newUser.timeZone, - language: { translate: newUserT, locale: newUser.locale ?? "en" }, - }) - .withAttendees(attendees) .withLocation({ location: bookingLocation || null, conferenceCredentialId: conferenceCredentialId ?? undefined, @@ -698,30 +697,29 @@ export class ManagedEventManualReassignmentService { }) ); - const emailBuilder = new CalendarEventBuilder() - .withBasicDetails({ - bookerUrl: bookerUrlForEmail, - title: newBooking.title, - startTime: dayjs(newBooking.startTime).utc().format(), - endTime: dayjs(newBooking.endTime).utc().format(), - additionalNotes: newBooking.description || undefined, - }) - .withEventType({ - id: targetEventTypeDetails.id, - slug: targetEventTypeDetails.slug, - description: newBooking.description, - schedulingType: parentEventType.schedulingType, - seatsPerTimeSlot: targetEventTypeDetails.seatsPerTimeSlot, - }) - .withOrganizer({ + const emailBuilder = new CalendarEventBuilder({ + bookerUrl: bookerUrlForEmail, + title: newBooking.title, + startTime: dayjs(newBooking.startTime).utc().format(), + endTime: dayjs(newBooking.endTime).utc().format(), + type: targetEventTypeDetails.slug, + organizer: { id: newUser.id, - name: newUser.name, + name: newUser.name || "Nameless", email: newUser.email, timeZone: newUser.timeZone, timeFormat: getTimeFormatStringFromUserTimeFormat(newUser.timeFormat), language: { translate: newUserT, locale: newUser.locale ?? "en" }, + }, + attendees: attendeesForEmail, + additionalNotes: newBooking.description || undefined, + }) + .withEventType({ + id: targetEventTypeDetails.id, + description: newBooking.description, + schedulingType: parentEventType.schedulingType, + seatsPerTimeSlot: targetEventTypeDetails.seatsPerTimeSlot, }) - .withAttendees(attendeesForEmail) .withLocation({ location: bookingLocation || null, }) diff --git a/packages/features/tasker/tasks/crm/lib/buildCalendarEvent.ts b/packages/features/tasker/tasks/crm/lib/buildCalendarEvent.ts index 955ff0b125..d51276083b 100644 --- a/packages/features/tasker/tasks/crm/lib/buildCalendarEvent.ts +++ b/packages/features/tasker/tasks/crm/lib/buildCalendarEvent.ts @@ -1,5 +1,8 @@ import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; -import { addVideoCallDataToEvent } from "@calcom/features/bookings/lib/handleNewBooking/addVideoCallDataToEvent"; +import type { BuiltCalendarEvent } from "@calcom/features/CalendarEventBuilder"; +import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder"; +import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; +import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; import { getTranslation } from "@calcom/i18n/server"; import prisma from "@calcom/prisma"; import type { CalendarEvent } from "@calcom/types/Calendar"; @@ -12,6 +15,7 @@ const buildCalendarEvent: (bookingUid: string) => Promise = async include: { user: { select: { + id: true, name: true, email: true, locale: true, @@ -24,6 +28,11 @@ const buildCalendarEvent: (bookingUid: string) => Promise = async select: { slug: true, bookingFields: true, + team: { + select: { + parentId: true, + }, + }, }, }, attendees: { @@ -51,6 +60,12 @@ const buildCalendarEvent: (bookingUid: string) => Promise = async throw new Error(`event type not found for booking ${bookingUid}`); } + const organizerOrganizationId = booking.eventType.team?.parentId + ? booking.eventType.team.parentId + : await ProfileRepository.findFirstOrganizationIdForUser({ + userId: booking.user.id, + }); + const bookerUrl = await getBookerBaseUrl(organizerOrganizationId ?? null); const organizerT = await getTranslation(booking.user?.locale ?? "en", "common"); const attendeePromises = []; @@ -68,12 +83,13 @@ const buildCalendarEvent: (bookingUid: string) => Promise = async const attendeeList = await Promise.all(attendeePromises); - let calendarEvent: CalendarEvent = { + let calendarEvent: BuiltCalendarEvent = { uid: bookingUid, type: booking.eventType.slug, title: booking.title, startTime: booking.startTime.toISOString(), endTime: booking.endTime.toISOString(), + bookerUrl, organizer: { email: booking.user.email, name: booking.user.name || "Nameless", @@ -89,7 +105,9 @@ const buildCalendarEvent: (bookingUid: string) => Promise = async }), }; - calendarEvent = addVideoCallDataToEvent(booking.references, calendarEvent); + calendarEvent = CalendarEventBuilder.fromEvent(calendarEvent) + .withVideoCallDataFromReferences(booking.references) + .build(); return calendarEvent; };