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; };