import dayjs from "@calcom/dayjs"; import { SYSTEM_PHONE_FIELDS, SystemField } from "@calcom/lib/bookings/SystemField"; import { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; const DATE_FORMAT = "YYYY-MM-DD"; const TIME_FORMAT = "HH:mm:ss"; export type BookingTimeStatusData = { id: number; uid: string | null; title: string; createdAt: Date; timeStatus: string; eventTypeId: number | null; eventLength: number; startTime: Date; endTime: Date; paid: boolean; userEmail: string; userUsername: string; rating: number | null; ratingFeedback: string | null; noShowHost: boolean; }; export type BookingWithAttendees = { uid: string; eventTypeId: number | null; attendees: { name: string; email: string; phoneNumber: string | null; noShow: boolean | null; }[]; seatsReferences: { attendee: { name: string; email: string; phoneNumber: string | null; noShow: boolean | null; }; }[]; responses: unknown; eventType: { bookingFields: unknown; } | null; tracking: { utm_source: string | null; utm_medium: string | null; utm_campaign: string | null; utm_term: string | null; utm_content: string | null; } | null; }; export type ProcessedBookingData = { noShowGuests: string | null; noShowGuestsCount: number; attendeeList: string[]; attendeePhoneNumbers: (string | null)[]; bookingQuestionResponses: Record; }; export type BookingFieldInfo = { name: string; label: string; }; export type ProcessBookingsResult = { bookingMap: Map; maxAttendees: number; allBookingQuestionLabels: Set; }; export function extractFieldValue(value: unknown): string | null { if (typeof value === "string" && value.trim()) return value; if (typeof value === "number" || typeof value === "boolean") return String(value); if (Array.isArray(value)) { const filtered = value.filter((v) => v != null && v !== ""); return filtered.length > 0 ? filtered.join(", ") : null; } if (value && typeof value === "object" && "value" in value) { const val = (value as { value: unknown }).value; return extractFieldValue(val); } return null; } export function isSystemField(fieldName: string): boolean { return SystemField.safeParse(fieldName).success; } export function getPhoneFieldsForSeatedEvent(bookingFields: unknown): BookingFieldInfo[] | null { const parsed = eventTypeBookingFields.safeParse(bookingFields); if (!parsed.success) return null; return parsed.data .filter((field) => field.type === "phone" && !SYSTEM_PHONE_FIELDS.has(field.name)) .map((field) => ({ name: field.name, label: field.label || field.name })); } export function getAllFieldsForNonSeatedEvent( bookingFields: unknown ): { fields: BookingFieldInfo[]; phoneFieldNames: Set } | null { const parsed = eventTypeBookingFields.safeParse(bookingFields); if (!parsed.success) return null; const phoneNames = new Set(); const fields = parsed.data .filter((field) => !isSystemField(field.name)) .map((field) => { if (field.type === "phone") { phoneNames.add(field.name); } return { name: field.name, label: field.label || field.name }; }); return { fields, phoneFieldNames: phoneNames }; } export function processBookingAttendees( booking: BookingWithAttendees, bookingFields: BookingFieldInfo[] | null, phoneFieldNames: Set | null, isSeatedEvent: boolean ): ProcessedBookingData { const attendeeList = booking.seatsReferences.length > 0 ? booking.seatsReferences.map((ref) => ref.attendee) : booking.attendees; const formattedAttendees: string[] = []; const noShowAttendees: string[] = []; const attendeePhoneNumbers: (string | null)[] = []; let noShowGuestsCount = 0; const bookingQuestionResponses: Record = {}; let systemPhoneValue: string | null = null; let firstCustomPhoneValue: string | null = null; if (booking.responses && typeof booking.responses === "object") { const responses = booking.responses as Record; systemPhoneValue = extractFieldValue(responses.attendeePhoneNumber) || extractFieldValue(responses.smsReminderNumber) || null; if (bookingFields) { for (const field of bookingFields) { const value = extractFieldValue(responses[field.name]); bookingQuestionResponses[field.label] = value; if (firstCustomPhoneValue === null && value !== null) { if (isSeatedEvent || phoneFieldNames?.has(field.name)) { firstCustomPhoneValue = value; } } } } } const phoneFallback = systemPhoneValue || firstCustomPhoneValue; for (const attendee of attendeeList) { if (attendee) { const formatted = `${attendee.name} (${attendee.email})`; formattedAttendees.push(formatted); attendeePhoneNumbers.push(attendee.phoneNumber || phoneFallback); if (attendee.noShow) { noShowAttendees.push(formatted); noShowGuestsCount++; } } } const noShowGuests = noShowAttendees.length > 0 ? noShowAttendees.join("; ") : null; return { noShowGuests, noShowGuestsCount, attendeeList: formattedAttendees, attendeePhoneNumbers, bookingQuestionResponses, }; } export function processBookingsForCsv(bookings: BookingWithAttendees[]): ProcessBookingsResult { const phoneFieldsCache = new Map(); const allFieldsCache = new Map }>(); const allBookingQuestionLabels = new Set(); let maxAttendees = 0; const bookingMap = new Map(); for (const booking of bookings) { const eventTypeId = booking.eventTypeId; const isSeatedEvent = booking.seatsReferences.length > 0; let bookingFields: BookingFieldInfo[] | null = null; let phoneFieldNames: Set | null = null; if (eventTypeId) { if (isSeatedEvent) { const cached = phoneFieldsCache.get(eventTypeId); if (cached !== undefined) { bookingFields = cached; } else if (booking.eventType?.bookingFields) { bookingFields = getPhoneFieldsForSeatedEvent(booking.eventType.bookingFields); if (bookingFields) { phoneFieldsCache.set(eventTypeId, bookingFields); bookingFields.forEach((field) => allBookingQuestionLabels.add(field.label)); } } } else { const cached = allFieldsCache.get(eventTypeId); if (cached !== undefined) { bookingFields = cached.fields; phoneFieldNames = cached.phoneFieldNames; } else if (booking.eventType?.bookingFields) { const result = getAllFieldsForNonSeatedEvent(booking.eventType.bookingFields); if (result) { bookingFields = result.fields; phoneFieldNames = result.phoneFieldNames; allFieldsCache.set(eventTypeId, result); bookingFields.forEach((field) => allBookingQuestionLabels.add(field.label)); } } } } const processedData = processBookingAttendees(booking, bookingFields, phoneFieldNames, isSeatedEvent); if (processedData.attendeeList.length > maxAttendees) { maxAttendees = processedData.attendeeList.length; } bookingMap.set(booking.uid, processedData); } return { bookingMap, maxAttendees, allBookingQuestionLabels }; } export function formatCsvRow( bookingTimeStatus: BookingTimeStatusData, processedData: ProcessedBookingData | null, maxAttendees: number, allBookingQuestionLabels: Set, timeZone: string ): Record { const dateAndTime = { createdAt: bookingTimeStatus.createdAt.toISOString(), createdAt_date: dayjs(bookingTimeStatus.createdAt).tz(timeZone).format(DATE_FORMAT), createdAt_time: dayjs(bookingTimeStatus.createdAt).tz(timeZone).format(TIME_FORMAT), startTime: bookingTimeStatus.startTime.toISOString(), startTime_date: dayjs(bookingTimeStatus.startTime).tz(timeZone).format(DATE_FORMAT), startTime_time: dayjs(bookingTimeStatus.startTime).tz(timeZone).format(TIME_FORMAT), endTime: bookingTimeStatus.endTime.toISOString(), endTime_date: dayjs(bookingTimeStatus.endTime).tz(timeZone).format(DATE_FORMAT), endTime_time: dayjs(bookingTimeStatus.endTime).tz(timeZone).format(TIME_FORMAT), }; const result: Record = { ...bookingTimeStatus, ...dateAndTime, noShowGuests: processedData?.noShowGuests || null, noShowGuestsCount: processedData?.noShowGuestsCount || 0, }; for (let i = 1; i <= maxAttendees; i++) { result[`attendee${i}`] = processedData?.attendeeList[i - 1] || null; result[`attendeePhone${i}`] = processedData?.attendeePhoneNumbers[i - 1] || null; } allBookingQuestionLabels.forEach((label) => { result[label] = processedData?.bookingQuestionResponses[label] || null; }); return result; } export function getUtmDataForBooking( booking: BookingWithAttendees | undefined ): Record { return { utm_source: booking?.tracking?.utm_source || "", utm_medium: booking?.tracking?.utm_medium || "", utm_campaign: booking?.tracking?.utm_campaign || "", utm_term: booking?.tracking?.utm_term || "", utm_content: booking?.tracking?.utm_content || "", }; } export function transformBookingsForCsv( csvData: BookingTimeStatusData[], bookings: BookingWithAttendees[], timeZone: string ): Record[] { const { bookingMap, maxAttendees, allBookingQuestionLabels } = processBookingsForCsv(bookings); const bookingsByUid = new Map(bookings.map((b) => [b.uid, b])); return csvData.map((bookingTimeStatus) => { const processedData = bookingTimeStatus.uid ? bookingMap.get(bookingTimeStatus.uid) : null; const booking = bookingTimeStatus.uid ? bookingsByUid.get(bookingTimeStatus.uid) : undefined; const row = formatCsvRow( bookingTimeStatus, processedData || null, maxAttendees, allBookingQuestionLabels, timeZone ); return { ...row, ...getUtmDataForBooking(booking), }; }); }