Skip to content

Commit 3082d6c

Browse files
dhairyashiildevin-ai-integration[bot]eunjae-lee
authored
feat: export all booking question fields in csv for non seated events in insights (calcom#25837)
* feat(insights): include all booking question fields in CSV export for non-seated events - For non-seated events: export all custom booking question fields (phone, text, number, select, multiselect, url, checkbox, textarea) excluding system fields - For seated events: preserve existing behavior (only phone-type fields) - Add extractFieldValue helper to handle all field value types - Fix phone fallback logic to only use actual phone field values - Optimize cache lookup with single get() instead of has() + get() * refactor(insights): extract CSV data transformation logic into separate module - Extract manipulation logic from getCsvData into csvDataTransformer.ts - Add extractFieldValue, isSystemField, and other helper functions - Add processBookingsForCsv and transformBookingsForCsv functions - Add comprehensive snapshot tests with dummy data - Tests demonstrate how input becomes output for various scenarios Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix(insights): update BookingWithAttendees type to match Prisma query result - Change noShow field type from boolean to boolean | null - Fixes type error TS2345 in InsightsBookingBaseService.ts Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
1 parent f98acb7 commit 3082d6c

4 files changed

Lines changed: 1393 additions & 140 deletions

File tree

packages/features/insights/services/InsightsBookingBaseService.ts

Lines changed: 4 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +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";
2120
import type { PrismaClient } from "@calcom/prisma";
2221
import { Prisma } from "@calcom/prisma/client";
2322
import { MembershipRole } from "@calcom/prisma/enums";
24-
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
23+
24+
import { transformBookingsForCsv, type BookingTimeStatusData } from "./csvDataTransformer";
2525

2626
// Utility function to build user hash map with avatar URL fallback
2727
export const buildHashMapForUsers = <
@@ -485,8 +485,6 @@ export class InsightsBookingBaseService {
485485
offset?: number;
486486
timeZone: string;
487487
}) {
488-
const DATE_FORMAT = "YYYY-MM-DD";
489-
const TIME_FORMAT = "HH:mm:ss";
490488
const baseConditions = await this.getBaseConditions();
491489

492490
// Get total count first
@@ -593,142 +591,8 @@ export class InsightsBookingBaseService {
593591
},
594592
});
595593

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-
>();
610-
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-
}
638-
639-
const attendeeList =
640-
booking.seatsReferences.length > 0
641-
? booking.seatsReferences.map((ref) => ref.attendee)
642-
: booking.attendees;
643-
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-
}
664-
}
665-
}
666-
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-
}
685-
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
699-
const data = csvData.map((bookingTimeStatus) => {
700-
const dateAndTime = {
701-
createdAt: bookingTimeStatus.createdAt.toISOString(),
702-
createdAt_date: dayjs(bookingTimeStatus.createdAt).tz(timeZone).format(DATE_FORMAT),
703-
createdAt_time: dayjs(bookingTimeStatus.createdAt).tz(timeZone).format(TIME_FORMAT),
704-
startTime: bookingTimeStatus.startTime.toISOString(),
705-
startTime_date: dayjs(bookingTimeStatus.startTime).tz(timeZone).format(DATE_FORMAT),
706-
startTime_time: dayjs(bookingTimeStatus.startTime).tz(timeZone).format(TIME_FORMAT),
707-
endTime: bookingTimeStatus.endTime.toISOString(),
708-
endTime_date: dayjs(bookingTimeStatus.endTime).tz(timeZone).format(DATE_FORMAT),
709-
endTime_time: dayjs(bookingTimeStatus.endTime).tz(timeZone).format(TIME_FORMAT),
710-
};
711-
712-
const attendeeData = bookingTimeStatus.uid ? finalBookingMap.get(bookingTimeStatus.uid) : null;
713-
714-
const result: Record<string, unknown> = {
715-
...bookingTimeStatus,
716-
...dateAndTime,
717-
noShowGuests: attendeeData?.noShowGuests || null,
718-
noShowGuestsCount: attendeeData?.noShowGuestsCount || 0,
719-
};
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;
731-
});
594+
// 3. Transform bookings data for CSV export
595+
const data = transformBookingsForCsv(csvData as BookingTimeStatusData[], bookings, timeZone);
732596

733597
return { data, total: totalCount };
734598
}

0 commit comments

Comments
 (0)