diff --git a/packages/features/insights/services/InsightsBookingBaseService.ts b/packages/features/insights/services/InsightsBookingBaseService.ts index 560686c2b8..27e1825dc5 100644 --- a/packages/features/insights/services/InsightsBookingBaseService.ts +++ b/packages/features/insights/services/InsightsBookingBaseService.ts @@ -17,9 +17,11 @@ import { extractDateRangeFromColumnFilters } from "@calcom/features/insights/lib import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils"; import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { SYSTEM_PHONE_FIELDS } from "@calcom/lib/bookings/SystemField"; import type { PrismaClient } from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; +import { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; // Utility function to build user hash map with avatar URL fallback export const buildHashMapForUsers = < @@ -552,7 +554,7 @@ export class InsightsBookingBaseService { return { data: csvData, total: totalCount }; } - // 2. Get all bookings with their attendees and seat references + // 2. Get all bookings with their attendees, seat references, and phone data const bookings = await this.prisma.booking.findMany({ where: { uid: { @@ -561,10 +563,12 @@ export class InsightsBookingBaseService { }, select: { uid: true, + eventTypeId: true, attendees: { select: { name: true, email: true, + phoneNumber: true, noShow: true, }, }, @@ -574,66 +578,124 @@ export class InsightsBookingBaseService { select: { name: true, email: true, + phoneNumber: true, noShow: true, }, }, }, }, + responses: true, + eventType: { + select: { + bookingFields: true, + }, + }, }, }); - // 3. Create booking map with attendee data (matching original logic) - const bookingMap = new Map( - bookings.map((booking) => { - const attendeeList = - booking.seatsReferences.length > 0 - ? booking.seatsReferences.map((ref) => ref.attendee) - : booking.attendees; + // 3. Process bookings: extract phone data and build attendee map + const phoneFieldsCache = new Map(); + const allPhoneFieldLabels = new Set(); + let maxAttendees = 0; + const finalBookingMap = new Map< + string, + { + noShowGuests: string | null; + noShowGuestsCount: number; + attendeeList: string[]; + attendeePhoneNumbers: (string | null)[]; + phoneQuestionResponses: Record; + } + >(); - // List all no-show guests (name and email) - const noShowGuests = - attendeeList - .filter((attendee) => attendee?.noShow) - .map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null)) - .filter(Boolean) // remove null values - .join("; ") || null; - const noShowGuestsCount = attendeeList.filter((attendee) => attendee?.noShow).length; + const extractPhoneValue = (value: unknown): string | null => { + if (typeof value === "string" && value.trim()) return value; + if (value && typeof value === "object" && "value" in value) { + const val = (value as { value: unknown }).value; + if (typeof val === "string" && val.trim()) return val; + } + return null; + }; - const formattedAttendees = attendeeList - .map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null)) - .filter(Boolean); + for (const booking of bookings) { + const eventTypeId = booking.eventTypeId; + let phoneFields: { name: string; label: string }[] | null = null; - return [booking.uid, { attendeeList: formattedAttendees, noShowGuests, noShowGuestsCount }]; - }) - ); - - // 4. Calculate max attendees for dynamic columns - const maxAttendees = Math.max( - ...Array.from(bookingMap.values()).map((data) => data.attendeeList.length), - 0 - ); - - // 5. Create final booking map with attendee fields - const finalBookingMap = new Map( - Array.from(bookingMap.entries()).map(([uid, data]) => { - const attendeeFields: Record = {}; - - for (let i = 1; i <= maxAttendees; i++) { - attendeeFields[`attendee${i}`] = data.attendeeList[i - 1] || null; + if (eventTypeId) { + if (phoneFieldsCache.has(eventTypeId)) { + phoneFields = phoneFieldsCache.get(eventTypeId) || null; + } else if (booking.eventType?.bookingFields) { + const parsed = eventTypeBookingFields.safeParse(booking.eventType.bookingFields); + if (parsed.success) { + phoneFields = parsed.data + .filter((field) => field.type === "phone" && !SYSTEM_PHONE_FIELDS.has(field.name)) + .map((field) => ({ name: field.name, label: field.label || field.name })); + phoneFieldsCache.set(eventTypeId, phoneFields); + phoneFields.forEach((field) => allPhoneFieldLabels.add(field.label)); + } } + } - return [ - uid, - { - noShowGuests: data.noShowGuests, - noShowGuestsCount: data.noShowGuestsCount, - ...attendeeFields, - }, - ]; - }) - ); + const attendeeList = + booking.seatsReferences.length > 0 + ? booking.seatsReferences.map((ref) => ref.attendee) + : booking.attendees; - // 6. Combine booking data with attendee data and add ISO timestamp columns + const formattedAttendees: string[] = []; + const noShowAttendees: string[] = []; + const attendeePhoneNumbers: (string | null)[] = []; + let noShowGuestsCount = 0; + + const phoneQuestionResponses: Record = {}; + let systemPhoneValue: string | null = null; + + if (booking.responses && typeof booking.responses === "object") { + const responses = booking.responses as Record; + + systemPhoneValue = + extractPhoneValue(responses.attendeePhoneNumber) || + extractPhoneValue(responses.smsReminderNumber) || + null; + + if (phoneFields) { + for (const field of phoneFields) { + phoneQuestionResponses[field.label] = extractPhoneValue(responses[field.name]); + } + } + } + + const firstPhoneQuestionValue = Object.values(phoneQuestionResponses).find((v) => v !== null) || null; + const phoneFallback = systemPhoneValue || firstPhoneQuestionValue; + + 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++; + } + } + } + + if (formattedAttendees.length > maxAttendees) { + maxAttendees = formattedAttendees.length; + } + + // List all no-show guests (name and email) + const noShowGuests = noShowAttendees.length > 0 ? noShowAttendees.join("; ") : null; + + finalBookingMap.set(booking.uid, { + noShowGuests, + noShowGuestsCount, + attendeeList: formattedAttendees, + attendeePhoneNumbers, + phoneQuestionResponses, + }); + } + + // 4. Combine booking data with attendee data and format for CSV const data = csvData.map((bookingTimeStatus) => { const dateAndTime = { createdAt: bookingTimeStatus.createdAt.toISOString(), @@ -647,46 +709,25 @@ export class InsightsBookingBaseService { endTime_time: dayjs(bookingTimeStatus.endTime).tz(timeZone).format(TIME_FORMAT), }; - if (!bookingTimeStatus.uid) { - // should not be reached because we filtered above - const nullAttendeeFields: Record = {}; - for (let i = 1; i <= maxAttendees; i++) { - nullAttendeeFields[`attendee${i}`] = null; - } + const attendeeData = bookingTimeStatus.uid ? finalBookingMap.get(bookingTimeStatus.uid) : null; - return { - ...bookingTimeStatus, - ...dateAndTime, - noShowGuests: null, - noShowGuestsCount: 0, - ...nullAttendeeFields, - }; - } - - const attendeeData = finalBookingMap.get(bookingTimeStatus.uid); - - if (!attendeeData) { - const nullAttendeeFields: Record = {}; - for (let i = 1; i <= maxAttendees; i++) { - nullAttendeeFields[`attendee${i}`] = null; - } - - return { - ...bookingTimeStatus, - ...dateAndTime, - noShowGuests: null, - noShowGuestsCount: 0, - ...nullAttendeeFields, - }; - } - - return { + const result: Record = { ...bookingTimeStatus, ...dateAndTime, - noShowGuests: attendeeData.noShowGuests, - noShowGuestsCount: attendeeData.noShowGuestsCount, - ...Object.fromEntries(Object.entries(attendeeData).filter(([key]) => key.startsWith("attendee"))), + noShowGuests: attendeeData?.noShowGuests || null, + noShowGuestsCount: attendeeData?.noShowGuestsCount || 0, }; + + for (let i = 1; i <= maxAttendees; i++) { + result[`attendee${i}`] = attendeeData?.attendeeList[i - 1] || null; + result[`attendeePhone${i}`] = attendeeData?.attendeePhoneNumbers[i - 1] || null; + } + + allPhoneFieldLabels.forEach((label) => { + result[label] = attendeeData?.phoneQuestionResponses[label] || null; + }); + + return result; }); return { data, total: totalCount }; diff --git a/packages/lib/bookings/SystemField.ts b/packages/lib/bookings/SystemField.ts index 9b8b4fe7f5..3dbe9a1b24 100644 --- a/packages/lib/bookings/SystemField.ts +++ b/packages/lib/bookings/SystemField.ts @@ -16,6 +16,13 @@ export const SystemField = z.enum([ export const SMS_REMINDER_NUMBER_FIELD = "smsReminderNumber"; export const CAL_AI_AGENT_PHONE_NUMBER_FIELD = "aiAgentCallPhoneNumber"; export const TITLE_FIELD = "title"; +export const ATTENDEE_PHONE_NUMBER_FIELD = "attendeePhoneNumber"; + +export const SYSTEM_PHONE_FIELDS = new Set([ + ATTENDEE_PHONE_NUMBER_FIELD, + SMS_REMINDER_NUMBER_FIELD, + CAL_AI_AGENT_PHONE_NUMBER_FIELD, +]); /** * Check if a field should be displayed in custom responses section.