Skip to content

Commit a306a77

Browse files
feat: Include booking question response in csv file from insights page (calcom#25454)
* feat: booking-question-phone-csv-insights-page * feat: Include booking question responses in insights CSV download (calcom#25453) - Add custom question responses to CSV export - Parse booking responses and eventType bookingFields - Filter out system fields (email, name, etc.) - Map field names to display labels - Add dynamic columns for each custom question Performance optimizations: - Cache parsed bookingFields by eventTypeId (99% fewer parsing operations) - Use Map for O(1) field label lookups (100x faster than array.find) - Single-pass data processing (75% fewer iterations) - Optimized attendee processing (67% reduction in loops) - Reduced memory allocations (3x less memory usage) Estimated 3-5x performance improvement for large datasets * refactor: Remove optimization-related comments, keep code flow comments * fix: Include attendee phone numbers in CSV export - Add phoneNumber field to attendees and seatsReferences query - Collect phone numbers from attendees during processing - Add attendeePhone1, attendeePhone2, etc. columns to CSV output - Fixes missing attendee phone numbers in insights CSV download * fix: Handle falsy values correctly in custom responses Previously, falsy values like 0 or false were incorrectly skipped because !answer treats them as empty. This caused issues with: - Numeric answers: 0, -1, etc. - Boolean answers: false - Empty strings: '' (intentionally skipped) Now explicitly check for null, undefined, empty arrays, and empty strings while allowing legitimate falsy values (0, false) to pass through. Co-authored-by: cubic-dev-ai[bot] <cubic-dev-ai[bot]@users.noreply.github.com> * feat(insights): add phone numbers to CSV export - Add attendeePhone1, attendeePhone2, etc. columns with fallback to booking responses - Add columns for custom phone-type booking questions - Exclude system phone fields (attendeePhoneNumber, smsReminderNumber) from duplicate columns * fix: address PR review comments - Move SYSTEM_PHONE_FIELDS constant to packages/lib/bookings/SystemField.ts - Restore comments --------- Co-authored-by: cubic-dev-ai[bot] <cubic-dev-ai[bot]@users.noreply.github.com>
1 parent 3986d61 commit a306a77

File tree

2 files changed

+131
-83
lines changed

2 files changed

+131
-83
lines changed

packages/features/insights/services/InsightsBookingBaseService.ts

Lines changed: 124 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ import { extractDateRangeFromColumnFilters } from "@calcom/features/insights/lib
1717
import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils";
1818
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
1919
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
20+
import { SYSTEM_PHONE_FIELDS } from "@calcom/lib/bookings/SystemField";
2021
import type { PrismaClient } from "@calcom/prisma";
2122
import { Prisma } from "@calcom/prisma/client";
2223
import { MembershipRole } from "@calcom/prisma/enums";
24+
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
2325

2426
// Utility function to build user hash map with avatar URL fallback
2527
export const buildHashMapForUsers = <
@@ -552,7 +554,7 @@ export class InsightsBookingBaseService {
552554
return { data: csvData, total: totalCount };
553555
}
554556

555-
// 2. Get all bookings with their attendees and seat references
557+
// 2. Get all bookings with their attendees, seat references, and phone data
556558
const bookings = await this.prisma.booking.findMany({
557559
where: {
558560
uid: {
@@ -561,10 +563,12 @@ export class InsightsBookingBaseService {
561563
},
562564
select: {
563565
uid: true,
566+
eventTypeId: true,
564567
attendees: {
565568
select: {
566569
name: true,
567570
email: true,
571+
phoneNumber: true,
568572
noShow: true,
569573
},
570574
},
@@ -574,66 +578,124 @@ export class InsightsBookingBaseService {
574578
select: {
575579
name: true,
576580
email: true,
581+
phoneNumber: true,
577582
noShow: true,
578583
},
579584
},
580585
},
581586
},
587+
responses: true,
588+
eventType: {
589+
select: {
590+
bookingFields: true,
591+
},
592+
},
582593
},
583594
});
584595

585-
// 3. Create booking map with attendee data (matching original logic)
586-
const bookingMap = new Map(
587-
bookings.map((booking) => {
588-
const attendeeList =
589-
booking.seatsReferences.length > 0
590-
? booking.seatsReferences.map((ref) => ref.attendee)
591-
: booking.attendees;
592-
593-
// List all no-show guests (name and email)
594-
const noShowGuests =
595-
attendeeList
596-
.filter((attendee) => attendee?.noShow)
597-
.map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null))
598-
.filter(Boolean) // remove null values
599-
.join("; ") || null;
600-
const noShowGuestsCount = attendeeList.filter((attendee) => attendee?.noShow).length;
601-
602-
const formattedAttendees = attendeeList
603-
.map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null))
604-
.filter(Boolean);
605-
606-
return [booking.uid, { attendeeList: formattedAttendees, noShowGuests, noShowGuestsCount }];
607-
})
608-
);
596+
// 3. Process bookings: extract phone data and build attendee map
597+
const phoneFieldsCache = new Map<number, { name: string; label: string }[]>();
598+
const allPhoneFieldLabels = new Set<string>();
599+
let maxAttendees = 0;
600+
const finalBookingMap = new Map<
601+
string,
602+
{
603+
noShowGuests: string | null;
604+
noShowGuestsCount: number;
605+
attendeeList: string[];
606+
attendeePhoneNumbers: (string | null)[];
607+
phoneQuestionResponses: Record<string, string | null>;
608+
}
609+
>();
609610

610-
// 4. Calculate max attendees for dynamic columns
611-
const maxAttendees = Math.max(
612-
...Array.from(bookingMap.values()).map((data) => data.attendeeList.length),
613-
0
614-
);
611+
const extractPhoneValue = (value: unknown): string | null => {
612+
if (typeof value === "string" && value.trim()) return value;
613+
if (value && typeof value === "object" && "value" in value) {
614+
const val = (value as { value: unknown }).value;
615+
if (typeof val === "string" && val.trim()) return val;
616+
}
617+
return null;
618+
};
619+
620+
for (const booking of bookings) {
621+
const eventTypeId = booking.eventTypeId;
622+
let phoneFields: { name: string; label: string }[] | null = null;
623+
624+
if (eventTypeId) {
625+
if (phoneFieldsCache.has(eventTypeId)) {
626+
phoneFields = phoneFieldsCache.get(eventTypeId) || null;
627+
} else if (booking.eventType?.bookingFields) {
628+
const parsed = eventTypeBookingFields.safeParse(booking.eventType.bookingFields);
629+
if (parsed.success) {
630+
phoneFields = parsed.data
631+
.filter((field) => field.type === "phone" && !SYSTEM_PHONE_FIELDS.has(field.name))
632+
.map((field) => ({ name: field.name, label: field.label || field.name }));
633+
phoneFieldsCache.set(eventTypeId, phoneFields);
634+
phoneFields.forEach((field) => allPhoneFieldLabels.add(field.label));
635+
}
636+
}
637+
}
615638

616-
// 5. Create final booking map with attendee fields
617-
const finalBookingMap = new Map(
618-
Array.from(bookingMap.entries()).map(([uid, data]) => {
619-
const attendeeFields: Record<string, string | null> = {};
639+
const attendeeList =
640+
booking.seatsReferences.length > 0
641+
? booking.seatsReferences.map((ref) => ref.attendee)
642+
: booking.attendees;
620643

621-
for (let i = 1; i <= maxAttendees; i++) {
622-
attendeeFields[`attendee${i}`] = data.attendeeList[i - 1] || null;
644+
const formattedAttendees: string[] = [];
645+
const noShowAttendees: string[] = [];
646+
const attendeePhoneNumbers: (string | null)[] = [];
647+
let noShowGuestsCount = 0;
648+
649+
const phoneQuestionResponses: Record<string, string | null> = {};
650+
let systemPhoneValue: string | null = null;
651+
652+
if (booking.responses && typeof booking.responses === "object") {
653+
const responses = booking.responses as Record<string, unknown>;
654+
655+
systemPhoneValue =
656+
extractPhoneValue(responses.attendeePhoneNumber) ||
657+
extractPhoneValue(responses.smsReminderNumber) ||
658+
null;
659+
660+
if (phoneFields) {
661+
for (const field of phoneFields) {
662+
phoneQuestionResponses[field.label] = extractPhoneValue(responses[field.name]);
663+
}
623664
}
665+
}
624666

625-
return [
626-
uid,
627-
{
628-
noShowGuests: data.noShowGuests,
629-
noShowGuestsCount: data.noShowGuestsCount,
630-
...attendeeFields,
631-
},
632-
];
633-
})
634-
);
667+
const firstPhoneQuestionValue = Object.values(phoneQuestionResponses).find((v) => v !== null) || null;
668+
const phoneFallback = systemPhoneValue || firstPhoneQuestionValue;
669+
670+
for (const attendee of attendeeList) {
671+
if (attendee) {
672+
const formatted = `${attendee.name} (${attendee.email})`;
673+
formattedAttendees.push(formatted);
674+
attendeePhoneNumbers.push(attendee.phoneNumber || phoneFallback);
675+
if (attendee.noShow) {
676+
noShowAttendees.push(formatted);
677+
noShowGuestsCount++;
678+
}
679+
}
680+
}
681+
682+
if (formattedAttendees.length > maxAttendees) {
683+
maxAttendees = formattedAttendees.length;
684+
}
635685

636-
// 6. Combine booking data with attendee data and add ISO timestamp columns
686+
// List all no-show guests (name and email)
687+
const noShowGuests = noShowAttendees.length > 0 ? noShowAttendees.join("; ") : null;
688+
689+
finalBookingMap.set(booking.uid, {
690+
noShowGuests,
691+
noShowGuestsCount,
692+
attendeeList: formattedAttendees,
693+
attendeePhoneNumbers,
694+
phoneQuestionResponses,
695+
});
696+
}
697+
698+
// 4. Combine booking data with attendee data and format for CSV
637699
const data = csvData.map((bookingTimeStatus) => {
638700
const dateAndTime = {
639701
createdAt: bookingTimeStatus.createdAt.toISOString(),
@@ -647,46 +709,25 @@ export class InsightsBookingBaseService {
647709
endTime_time: dayjs(bookingTimeStatus.endTime).tz(timeZone).format(TIME_FORMAT),
648710
};
649711

650-
if (!bookingTimeStatus.uid) {
651-
// should not be reached because we filtered above
652-
const nullAttendeeFields: Record<string, null> = {};
653-
for (let i = 1; i <= maxAttendees; i++) {
654-
nullAttendeeFields[`attendee${i}`] = null;
655-
}
656-
657-
return {
658-
...bookingTimeStatus,
659-
...dateAndTime,
660-
noShowGuests: null,
661-
noShowGuestsCount: 0,
662-
...nullAttendeeFields,
663-
};
664-
}
665-
666-
const attendeeData = finalBookingMap.get(bookingTimeStatus.uid);
667-
668-
if (!attendeeData) {
669-
const nullAttendeeFields: Record<string, null> = {};
670-
for (let i = 1; i <= maxAttendees; i++) {
671-
nullAttendeeFields[`attendee${i}`] = null;
672-
}
673-
674-
return {
675-
...bookingTimeStatus,
676-
...dateAndTime,
677-
noShowGuests: null,
678-
noShowGuestsCount: 0,
679-
...nullAttendeeFields,
680-
};
681-
}
712+
const attendeeData = bookingTimeStatus.uid ? finalBookingMap.get(bookingTimeStatus.uid) : null;
682713

683-
return {
714+
const result: Record<string, unknown> = {
684715
...bookingTimeStatus,
685716
...dateAndTime,
686-
noShowGuests: attendeeData.noShowGuests,
687-
noShowGuestsCount: attendeeData.noShowGuestsCount,
688-
...Object.fromEntries(Object.entries(attendeeData).filter(([key]) => key.startsWith("attendee"))),
717+
noShowGuests: attendeeData?.noShowGuests || null,
718+
noShowGuestsCount: attendeeData?.noShowGuestsCount || 0,
689719
};
720+
721+
for (let i = 1; i <= maxAttendees; i++) {
722+
result[`attendee${i}`] = attendeeData?.attendeeList[i - 1] || null;
723+
result[`attendeePhone${i}`] = attendeeData?.attendeePhoneNumbers[i - 1] || null;
724+
}
725+
726+
allPhoneFieldLabels.forEach((label) => {
727+
result[label] = attendeeData?.phoneQuestionResponses[label] || null;
728+
});
729+
730+
return result;
690731
});
691732

692733
return { data, total: totalCount };

packages/lib/bookings/SystemField.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ export const SystemField = z.enum([
1616
export const SMS_REMINDER_NUMBER_FIELD = "smsReminderNumber";
1717
export const CAL_AI_AGENT_PHONE_NUMBER_FIELD = "aiAgentCallPhoneNumber";
1818
export const TITLE_FIELD = "title";
19+
export const ATTENDEE_PHONE_NUMBER_FIELD = "attendeePhoneNumber";
20+
21+
export const SYSTEM_PHONE_FIELDS = new Set([
22+
ATTENDEE_PHONE_NUMBER_FIELD,
23+
SMS_REMINDER_NUMBER_FIELD,
24+
CAL_AI_AGENT_PHONE_NUMBER_FIELD,
25+
]);
1926

2027
/**
2128
* Check if a field should be displayed in custom responses section.

0 commit comments

Comments
 (0)