import { ALL_APPS } from "@calcom/app-store/utils"; import { getAssignmentReasonCategory } from "@calcom/features/bookings/lib/getAssignmentReasonCategory"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; import { type EventTypeBrandingData, getEventTypeService, } from "@calcom/features/eventtypes/di/EventTypeService.container"; import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; import { getTranslation } from "@calcom/i18n/server"; 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"; type CalendarEventRequiredFields = Required< Pick >; 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 ) { const translate = await getTranslation(user.locale ?? "en", "common"); return { id: user.id, name: user.name || "Nameless", email: user.email, username: user.username || undefined, timeZone: user.timeZone, language: { translate, locale: user.locale ?? "en" }, timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat), } satisfies Person; } async function _buildPersonFromAttendee( attendee: Pick & { bookingSeat: Pick< BookingSeat, "id" | "referenceUid" | "bookingId" | "metadata" | "data" | "attendeeId" > | null; } ) { const translate = await getTranslation(attendee.locale ?? "en", "common"); return { name: attendee.name ?? "", email: attendee.email, timeZone: attendee.timeZone, language: { translate, locale: attendee.locale ?? "en" }, phoneNumber: attendee.phoneNumber, bookingSeat: attendee.bookingSeat, } satisfies Person; } export type BuiltCalendarEvent = Omit & { bookerUrl: string }; export type BookingForCalEventBuilder = NonNullable< Awaited> >; export type BookingMetaOptions = { conferenceCredentialId?: number; platformClientId?: string; platformRescheduleUrl?: string; platformCancelUrl?: string; platformBookingUrl?: string; }; export class CalendarEventBuilder { private event: CalendarEventBuilderInit; constructor(existingEvent: CalendarEventBuilderInit) { this.event = existingEvent; } static fromEvent(event: CalendarEventBuilderInit) { return new CalendarEventBuilder(event); } /** * Builds a CalendarEventBuilder instance from a booking. */ static async fromBooking( booking: BookingForCalEventBuilder, meta: BookingMetaOptions = {} ): Promise { const { uid, user, eventType } = booking; 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 { description, attendees, references, title, startTime, endTime, location, responses, customInputs, iCalUID, iCalSequence, oneTimePassword, seatsReferences, assignmentReason, } = booking; const { conferenceCredentialId, platformRescheduleUrl = "", platformClientId = "", platformCancelUrl = "", platformBookingUrl = "", } = meta; const organizerPerson = await _buildPersonFromUser(user); const attendeesList = await Promise.all(attendees.map(_buildPersonFromAttendee)); const additionalNotes = description || undefined; const videoRef = references.find((r) => r.type.endsWith("_video")); const videoCallData = videoRef ? { type: videoRef.type, id: videoRef.meetingId, password: videoRef.meetingPassword, url: videoRef.meetingUrl, } : undefined; const appsStatus: AppsStatus[] = []; const organizationId = user.profiles?.[0]?.organizationId ?? null; const bookerUrl = await getBookerBaseUrl(eventType.team?.parentId ?? organizationId); const parsedBookingResponses = bookingResponsesSchema.safeParse(responses); const bookingResponses = parsedBookingResponses.success ? parsedBookingResponses.data : null; const calEventResponses = getCalEventResponses({ booking, bookingFields: eventType.bookingFields, }); // custom inputs are the old system to record booking responses const parsedCustomInputs = typeof customInputs === "object" ? (customInputs as Record) : null; 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 .withEventType({ id: eventType.id, description: eventType.description, hideCalendarNotes: eventType.hideCalendarNotes, hideCalendarEventDetails: eventType.hideCalendarEventDetails, hideOrganizerEmail: eventType.hideOrganizerEmail, schedulingType: eventType.schedulingType, seatsPerTimeSlot: eventType.seatsPerTimeSlot, seatsShowAttendees: !!eventType.seatsShowAttendees, seatsShowAvailabilityCount: !!eventType.seatsShowAvailabilityCount, customReplyToEmail: eventType.customReplyToEmail, disableRescheduling: eventType.disableRescheduling ?? false, disableCancelling: eventType.disableCancelling ?? false, }) .withMetadataAndResponses({ additionalNotes, customInputs: parsedCustomInputs, responses: calEventResponses.responses, userFieldsResponses: calEventResponses.userFieldsResponses, }) .withLocation({ location, conferenceCredentialId }) .withIdentifiers({ iCalUID: iCalUID || undefined, iCalSequence }) .withConfirmation({ requiresConfirmation: !!eventType.requiresConfirmation, isConfirmedByDefault: !eventType.requiresConfirmation, }) .withPlatformVariables({ platformClientId, platformRescheduleUrl, platformCancelUrl, platformBookingUrl, }) .withRecurring(recurring) .withUid(uid) .withOneTimePassword(oneTimePassword) .withOrganization(organizationId) .withAssignmentReason( assignmentReason?.[0]?.reasonEnum ? { category: getAssignmentReasonCategory(assignmentReason[0].reasonEnum), details: assignmentReason[0].reasonString ?? null, } : null ) .withHideBranding( await getEventTypeService().shouldHideBrandingForEventType(eventType.id, { team: eventType.team ? { hideBranding: eventType.team.hideBranding, parent: eventType.team.parent } : null, owner: { id: user.id, hideBranding: user.hideBranding, profiles: user.profiles ?? [], }, } satisfies EventTypeBrandingData) ); // Seats if (seatsReferences?.length && bookingResponses) { const currentSeat = seatsReferences.find( (s) => s.attendee.email === bookingResponses.email || (bookingResponses.attendeePhoneNumber && s.attendee.phoneNumber === bookingResponses.attendeePhoneNumber) ); if (currentSeat) builder.withAttendeeSeatId(currentSeat.referenceUid); } // Video if (videoCallData?.url) { builder.withVideoCallData({ ...videoCallData, id: videoCallData.id ?? "", password: videoCallData.password ?? "", url: videoCallData.url, }); } references .filter((r) => r?.type) .forEach((ref) => { const appName = APP_TYPE_TO_NAME_MAP.get(ref.type) || ref.type.replace("_", "-"); appsStatus.push({ appName, type: ref.type, success: ref.uid ? 1 : 0, failures: ref.uid ? 0 : 1, errors: [], }); }); if (appsStatus.length) { builder.withAppsStatus(appsStatus); } // Team & calendars if (eventType.team) { // We need to get the team members assigned to the booking // In the DB team members are stored in the Attendee table const bookingAttendees = booking.attendees; const hostsToInclude = eventType.hosts.filter((host) => bookingAttendees.some((attendee) => attendee.email === host.user.email) ); const hostsWithoutOrganizerData = hostsToInclude.filter((host) => host.user.email !== user.email); const hostsWithoutOrganizer = await Promise.all( hostsWithoutOrganizerData.map((host) => _buildPersonFromUser(host.user)) ); const hostCalendars = [ user.destinationCalendar, ...hostsWithoutOrganizerData.map((h) => h.user.destinationCalendar).filter(Boolean), user.destinationCalendar, ].filter(Boolean) as NonNullable[]; builder .withTeam({ id: eventType.team.id, name: eventType.team.name || "", members: hostsWithoutOrganizer, }) .withDestinationCalendar(hostCalendars); } else if (user.destinationCalendar) { builder.withDestinationCalendar([user.destinationCalendar]); } return builder; } withEventType(eventType: { description?: string | null; id: number; hideCalendarNotes?: boolean; hideCalendarEventDetails?: boolean; hideOrganizerEmail?: boolean; schedulingType?: SchedulingType | null; seatsPerTimeSlot?: number | null; seatsShowAttendees?: boolean | null; seatsShowAvailabilityCount?: boolean | null; customReplyToEmail?: string | null; disableRescheduling?: boolean; disableCancelling?: boolean; }) { this.event = { ...this.event, description: eventType.description, eventTypeId: eventType.id, hideCalendarNotes: eventType.hideCalendarNotes, hideCalendarEventDetails: eventType.hideCalendarEventDetails, hideOrganizerEmail: eventType.hideOrganizerEmail, schedulingType: eventType.schedulingType, seatsPerTimeSlot: eventType.seatsPerTimeSlot, // if seats are not enabled we should default true seatsShowAttendees: eventType.seatsPerTimeSlot ? eventType.seatsShowAttendees : true, seatsShowAvailabilityCount: eventType.seatsPerTimeSlot ? eventType.seatsShowAvailabilityCount : true, customReplyToEmail: eventType.customReplyToEmail, disableRescheduling: eventType.disableRescheduling ?? false, disableCancelling: eventType.disableCancelling ?? false, }; return this; } withMetadataAndResponses({ additionalNotes, customInputs, responses, userFieldsResponses, }: { additionalNotes?: string | null; customInputs?: Prisma.JsonObject | null; responses?: CalEventResponses | null; userFieldsResponses?: CalEventResponses | null; }) { this.event = { ...this.event, additionalNotes, customInputs, responses, userFieldsResponses, }; return this; } withLocation({ location, conferenceCredentialId, }: { location: string | null; conferenceCredentialId?: number; }) { this.event = { ...this.event, location, conferenceCredentialId, }; return this; } withDestinationCalendar(destinationCalendar: CalendarEvent["destinationCalendar"]) { this.event = { ...this.event, destinationCalendar, }; return this; } withIdentifiers({ iCalUID, iCalSequence }: { iCalUID?: string; iCalSequence?: number }) { this.event = { ...this.event, iCalUID: iCalUID ?? this.event.iCalUID, iCalSequence: iCalSequence ?? this.event.iCalSequence, }; return this; } withConfirmation({ requiresConfirmation, isConfirmedByDefault, }: { requiresConfirmation: boolean; isConfirmedByDefault: boolean; }) { this.event = { ...this.event, requiresConfirmation, oneTimePassword: isConfirmedByDefault ? null : undefined, }; return this; } withPlatformVariables({ platformClientId, platformRescheduleUrl, platformCancelUrl, platformBookingUrl, }: { platformClientId?: string | null; platformRescheduleUrl?: string | null; platformCancelUrl?: string | null; platformBookingUrl?: string | null; }) { this.event = { ...this.event, platformClientId, platformRescheduleUrl, platformCancelUrl, platformBookingUrl, }; return this; } withAppsStatus(appsStatus?: AppsStatus[]) { this.event = { ...this.event, appsStatus, }; return this; } withVideoCallData(videoCallData?: VideoCallData) { this.event = { ...this.event, videoCallData, }; return this; } withTeam(team?: { name: string; members: Person[]; id: number }) { if (!team) { return this; } this.event = { ...this.event, team, }; return this; } withRecurring(recurringEvent?: { count: number; freq: number; interval: number }) { this.event = { ...this.event, recurringEvent, }; return this; } withAttendeeSeatId(attendeeSeatId?: string) { this.event = { ...this.event, attendeeSeatId, }; return this; } withUid(uid: string | null) { this.event = { ...this.event, uid, }; return this; } withRecurringEventId(recurringEventId?: string | null) { if (!recurringEventId) { return this; } this.event = { ...this.event, existingRecurringEvent: { recurringEventId, }, }; return this; } withOneTimePassword(oneTimePassword?: string | null) { this.event = { ...this.event, oneTimePassword, }; return this; } withOrganization(organizationId?: number | null) { this.event = { ...this.event, organizationId, }; return this; } withHashedLink(hashedLink?: string | null) { this.event = { ...this.event, hashedLink, }; return this; } withAssignmentReason(assignmentReason?: { category: string; details?: string | null } | null) { this.event = { ...this.event, assignmentReason, }; return this; } withHideBranding(hideBranding?: boolean) { this.event = { ...this.event, hideBranding, }; return this; } withVideoCallDataFromReferences(bookingReferences: BookingReference[]): this { const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video")); 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; } }