From 3082d6c2f360f1180a7e9e67c5573ec1eed43a54 Mon Sep 17 00:00:00 2001 From: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:05:21 +0530 Subject: [PATCH] feat: export all booking question fields in csv for non seated events in insights (#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 * 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 --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: eunjae@cal.com --- .../services/InsightsBookingBaseService.ts | 144 +--- .../csvDataTransformer.test.ts.snap | 326 ++++++++ .../__tests__/csvDataTransformer.test.ts | 764 ++++++++++++++++++ .../insights/services/csvDataTransformer.ts | 299 +++++++ 4 files changed, 1393 insertions(+), 140 deletions(-) create mode 100644 packages/features/insights/services/__tests__/__snapshots__/csvDataTransformer.test.ts.snap create mode 100644 packages/features/insights/services/__tests__/csvDataTransformer.test.ts create mode 100644 packages/features/insights/services/csvDataTransformer.ts diff --git a/packages/features/insights/services/InsightsBookingBaseService.ts b/packages/features/insights/services/InsightsBookingBaseService.ts index 27e1825dc5..329cfd2596 100644 --- a/packages/features/insights/services/InsightsBookingBaseService.ts +++ b/packages/features/insights/services/InsightsBookingBaseService.ts @@ -17,11 +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"; + +import { transformBookingsForCsv, type BookingTimeStatusData } from "./csvDataTransformer"; // Utility function to build user hash map with avatar URL fallback export const buildHashMapForUsers = < @@ -485,8 +485,6 @@ export class InsightsBookingBaseService { offset?: number; timeZone: string; }) { - const DATE_FORMAT = "YYYY-MM-DD"; - const TIME_FORMAT = "HH:mm:ss"; const baseConditions = await this.getBaseConditions(); // Get total count first @@ -593,142 +591,8 @@ export class InsightsBookingBaseService { }, }); - // 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; - } - >(); - - 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; - }; - - for (const booking of bookings) { - const eventTypeId = booking.eventTypeId; - let phoneFields: { name: string; label: string }[] | null = 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)); - } - } - } - - 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 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(), - 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 attendeeData = bookingTimeStatus.uid ? finalBookingMap.get(bookingTimeStatus.uid) : null; - - const result: Record = { - ...bookingTimeStatus, - ...dateAndTime, - 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; - }); + // 3. Transform bookings data for CSV export + const data = transformBookingsForCsv(csvData as BookingTimeStatusData[], bookings, timeZone); return { data, total: totalCount }; } diff --git a/packages/features/insights/services/__tests__/__snapshots__/csvDataTransformer.test.ts.snap b/packages/features/insights/services/__tests__/__snapshots__/csvDataTransformer.test.ts.snap new file mode 100644 index 0000000000..e96c9a57a7 --- /dev/null +++ b/packages/features/insights/services/__tests__/__snapshots__/csvDataTransformer.test.ts.snap @@ -0,0 +1,326 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`csvDataTransformer > extractFieldValue > snapshot: various input types 1`] = ` +[ + { + "input": ""simple string"", + "output": "simple string", + }, + { + "input": "42", + "output": "42", + }, + { + "input": "true", + "output": "true", + }, + { + "input": "false", + "output": "false", + }, + { + "input": "["option1","option2"]", + "output": "option1, option2", + }, + { + "input": "{"value":"wrapped"}", + "output": "wrapped", + }, + { + "input": "{"value":["multi","select"]}", + "output": "multi, select", + }, + { + "input": "null", + "output": null, + }, + { + "input": """", + "output": null, + }, + { + "input": "["",null,"valid"]", + "output": "valid", + }, +] +`; + +exports[`csvDataTransformer > formatCsvRow > snapshot: complete CSV row formatting 1`] = ` +{ + "Additional Notes": null, + "Budget": "50000", + "Company": "Tech Corp", + "Job Title": "Senior Engineer", + "Meeting Purpose": "Quarterly Review", + "attendee1": "Alice Johnson (alice@example.com)", + "attendee2": "Bob Smith (bob@example.com)", + "attendee3": "Late Person (late@example.com)", + "attendeePhone1": "+1111111111", + "attendeePhone2": "+2222222222", + "attendeePhone3": null, + "createdAt": "2024-03-20T09:30:00.000Z", + "createdAt_date": "2024-03-20", + "createdAt_time": "02:30:00", + "endTime": "2024-03-20T16:00:00.000Z", + "endTime_date": "2024-03-20", + "endTime_time": "09:00:00", + "eventLength": 60, + "eventTypeId": 5, + "id": 123, + "noShowGuests": "Late Person (late@example.com)", + "noShowGuestsCount": 1, + "noShowHost": false, + "paid": true, + "rating": 4, + "ratingFeedback": "Very productive", + "startTime": "2024-03-20T15:00:00.000Z", + "startTime_date": "2024-03-20", + "startTime_time": "08:00:00", + "timeStatus": "completed", + "title": "Strategy Meeting", + "uid": "booking-abc-123", + "userEmail": "manager@company.com", + "userUsername": "manager", +} +`; + +exports[`csvDataTransformer > getAllFieldsForNonSeatedEvent > snapshot: all fields extraction for non-seated events 1`] = ` +{ + "fields": [ + { + "label": "Contact Phone", + "name": "customPhone", + }, + { + "label": "Company Name", + "name": "company", + }, + { + "label": "Job Title", + "name": "jobTitle", + }, + { + "label": "Budget", + "name": "budget", + }, + { + "label": "Interests", + "name": "interests", + }, + { + "label": "Website URL", + "name": "website", + }, + { + "label": "I agree to terms", + "name": "agreeToTerms", + }, + { + "label": "Additional Notes", + "name": "additionalNotes", + }, + ], + "phoneFieldNames": [ + "customPhone", + ], +} +`; + +exports[`csvDataTransformer > getPhoneFieldsForSeatedEvent > snapshot: phone fields extraction for seated events 1`] = ` +[ + { + "label": "Primary Phone Number", + "name": "primaryPhone", + }, + { + "label": "Secondary Phone", + "name": "secondaryPhone", + }, +] +`; + +exports[`csvDataTransformer > processBookingAttendees > snapshot: complete booking processing 1`] = ` +{ + "attendeeList": [ + "Alice Johnson (alice@example.com)", + "Bob Smith (bob@example.com)", + "Carol White (carol@example.com)", + ], + "attendeePhoneNumbers": [ + "+1111111111", + "+0000000000", + "+0000000000", + ], + "bookingQuestionResponses": { + "Agreed to Terms": "true", + "Budget": "10000", + "Company": "Tech Corp", + "Interests": "AI, Cloud", + "Job Title": "Engineer", + }, + "noShowGuests": "Bob Smith (bob@example.com)", + "noShowGuestsCount": 1, +} +`; + +exports[`csvDataTransformer > processBookingsForCsv > snapshot: processing multiple bookings with different event types 1`] = ` +{ + "allBookingQuestionLabels": [ + "Company Name", + "How did you hear about us?", + "Contact Phone", + "Emergency Contact", + ], + "bookings": [ + [ + "non-seated-booking", + { + "attendeeList": [ + "John Doe (john@example.com)", + "Jane Smith (jane@example.com)", + ], + "attendeePhoneNumbers": [ + "+1234567890", + "+9999999999", + ], + "bookingQuestionResponses": { + "Company Name": "Acme Inc", + "Contact Phone": "+9999999999", + "How did you hear about us?": "Google", + }, + "noShowGuests": "Jane Smith (jane@example.com)", + "noShowGuestsCount": 1, + }, + ], + [ + "seated-booking", + { + "attendeeList": [ + "Seat A (seata@example.com)", + "Seat B (seatb@example.com)", + ], + "attendeePhoneNumbers": [ + "+5555555555", + "+5555555555", + ], + "bookingQuestionResponses": { + "Emergency Contact": "+5555555555", + }, + "noShowGuests": null, + "noShowGuestsCount": 0, + }, + ], + ], + "maxAttendees": 2, +} +`; + +exports[`csvDataTransformer > transformBookingsForCsv > snapshot: complete transformation pipeline 1`] = ` +[ + { + "Budget": "25000", + "Company": "Client Corp", + "Emergency Contact": null, + "Timeline": "Q2 2024", + "attendee1": "Client A (clienta@example.com)", + "attendee2": null, + "attendee3": null, + "attendeePhone1": "+1234567890", + "attendeePhone2": null, + "attendeePhone3": null, + "createdAt": "2024-02-01T08:00:00.000Z", + "createdAt_date": "2024-02-01", + "createdAt_time": "08:00:00", + "endTime": "2024-02-01T10:30:00.000Z", + "endTime_date": "2024-02-01", + "endTime_time": "10:30:00", + "eventLength": 30, + "eventTypeId": 1, + "id": 1, + "noShowGuests": null, + "noShowGuestsCount": 0, + "noShowHost": false, + "paid": false, + "rating": 5, + "ratingFeedback": "Excellent", + "startTime": "2024-02-01T10:00:00.000Z", + "startTime_date": "2024-02-01", + "startTime_time": "10:00:00", + "timeStatus": "completed", + "title": "Sales Call", + "uid": "booking-1", + "userEmail": "sales@company.com", + "userUsername": "sales", + }, + { + "Budget": null, + "Company": null, + "Emergency Contact": "+5555555555", + "Timeline": null, + "attendee1": "Participant 1 (p1@example.com)", + "attendee2": "Participant 2 (p2@example.com)", + "attendee3": "Participant 3 (p3@example.com)", + "attendeePhone1": "+5555555555", + "attendeePhone2": "+5555555555", + "attendeePhone3": "+9876543210", + "createdAt": "2024-02-02T09:00:00.000Z", + "createdAt_date": "2024-02-02", + "createdAt_time": "09:00:00", + "endTime": "2024-02-02T16:00:00.000Z", + "endTime_date": "2024-02-02", + "endTime_time": "16:00:00", + "eventLength": 120, + "eventTypeId": 2, + "id": 2, + "noShowGuests": "Participant 2 (p2@example.com)", + "noShowGuestsCount": 1, + "noShowHost": false, + "paid": true, + "rating": null, + "ratingFeedback": null, + "startTime": "2024-02-02T14:00:00.000Z", + "startTime_date": "2024-02-02", + "startTime_time": "14:00:00", + "timeStatus": "completed", + "title": "Team Workshop", + "uid": "booking-2", + "userEmail": "trainer@company.com", + "userUsername": "trainer", + }, + { + "Budget": null, + "Company": null, + "Emergency Contact": null, + "Timeline": null, + "attendee1": null, + "attendee2": null, + "attendee3": null, + "attendeePhone1": null, + "attendeePhone2": null, + "attendeePhone3": null, + "createdAt": "2024-02-03T10:00:00.000Z", + "createdAt_date": "2024-02-03", + "createdAt_time": "10:00:00", + "endTime": "2024-02-03T15:45:00.000Z", + "endTime_date": "2024-02-03", + "endTime_time": "15:45:00", + "eventLength": 45, + "eventTypeId": null, + "id": 3, + "noShowGuests": null, + "noShowGuestsCount": 0, + "noShowHost": false, + "paid": false, + "rating": null, + "ratingFeedback": null, + "startTime": "2024-02-03T15:00:00.000Z", + "startTime_date": "2024-02-03", + "startTime_time": "15:00:00", + "timeStatus": "upcoming", + "title": "Orphan Booking", + "uid": null, + "userEmail": "user@company.com", + "userUsername": "user", + }, +] +`; diff --git a/packages/features/insights/services/__tests__/csvDataTransformer.test.ts b/packages/features/insights/services/__tests__/csvDataTransformer.test.ts new file mode 100644 index 0000000000..eded987768 --- /dev/null +++ b/packages/features/insights/services/__tests__/csvDataTransformer.test.ts @@ -0,0 +1,764 @@ +import { describe, it, expect } from "vitest"; + +import { + extractFieldValue, + isSystemField, + getPhoneFieldsForSeatedEvent, + getAllFieldsForNonSeatedEvent, + processBookingAttendees, + processBookingsForCsv, + formatCsvRow, + transformBookingsForCsv, + type BookingWithAttendees, + type BookingTimeStatusData, +} from "../csvDataTransformer"; + +describe("csvDataTransformer", () => { + describe("extractFieldValue", () => { + it("should extract string values", () => { + expect(extractFieldValue("hello")).toBe("hello"); + expect(extractFieldValue(" hello ")).toBe(" hello "); + }); + + it("should return null for empty strings", () => { + expect(extractFieldValue("")).toBe(null); + expect(extractFieldValue(" ")).toBe(null); + }); + + it("should convert numbers to strings", () => { + expect(extractFieldValue(42)).toBe("42"); + expect(extractFieldValue(0)).toBe("0"); + expect(extractFieldValue(-5)).toBe("-5"); + }); + + it("should convert booleans to strings", () => { + expect(extractFieldValue(true)).toBe("true"); + expect(extractFieldValue(false)).toBe("false"); + }); + + it("should join arrays with comma separator", () => { + expect(extractFieldValue(["a", "b", "c"])).toBe("a, b, c"); + expect(extractFieldValue([1, 2, 3])).toBe("1, 2, 3"); + }); + + it("should filter out null and empty values from arrays", () => { + expect(extractFieldValue(["a", null, "b", "", "c"])).toBe("a, b, c"); + expect(extractFieldValue([null, "", undefined])).toBe(null); + }); + + it("should extract value from nested objects with value property", () => { + expect(extractFieldValue({ value: "nested" })).toBe("nested"); + expect(extractFieldValue({ value: 123 })).toBe("123"); + expect(extractFieldValue({ value: ["a", "b"] })).toBe("a, b"); + }); + + it("should handle deeply nested value objects", () => { + expect(extractFieldValue({ value: { value: "deep" } })).toBe("deep"); + }); + + it("should return null for null and undefined", () => { + expect(extractFieldValue(null)).toBe(null); + expect(extractFieldValue(undefined)).toBe(null); + }); + + it("should return null for objects without value property", () => { + expect(extractFieldValue({ other: "prop" })).toBe(null); + }); + + it("snapshot: various input types", () => { + const inputs = [ + "simple string", + 42, + true, + false, + ["option1", "option2"], + { value: "wrapped" }, + { value: ["multi", "select"] }, + null, + "", + ["", null, "valid"], + ]; + + const results = inputs.map((input) => ({ + input: JSON.stringify(input), + output: extractFieldValue(input), + })); + + expect(results).toMatchSnapshot(); + }); + }); + + describe("isSystemField", () => { + it("should identify system fields", () => { + expect(isSystemField("name")).toBe(true); + expect(isSystemField("email")).toBe(true); + expect(isSystemField("location")).toBe(true); + expect(isSystemField("title")).toBe(true); + expect(isSystemField("notes")).toBe(true); + expect(isSystemField("guests")).toBe(true); + expect(isSystemField("rescheduleReason")).toBe(true); + expect(isSystemField("smsReminderNumber")).toBe(true); + expect(isSystemField("attendeePhoneNumber")).toBe(true); + expect(isSystemField("aiAgentCallPhoneNumber")).toBe(true); + }); + + it("should return false for custom fields", () => { + expect(isSystemField("customField")).toBe(false); + expect(isSystemField("phone")).toBe(false); + expect(isSystemField("company")).toBe(false); + expect(isSystemField("howDidYouHear")).toBe(false); + }); + }); + + describe("getPhoneFieldsForSeatedEvent", () => { + it("should extract only phone type fields excluding system phone fields", () => { + const bookingFields = [ + { name: "customPhone", type: "phone", label: "Custom Phone" }, + { name: "attendeePhoneNumber", type: "phone", label: "Attendee Phone" }, + { name: "smsReminderNumber", type: "phone", label: "SMS Reminder" }, + { name: "company", type: "text", label: "Company" }, + ]; + + const result = getPhoneFieldsForSeatedEvent(bookingFields); + + expect(result).toEqual([{ name: "customPhone", label: "Custom Phone" }]); + }); + + it("should return null for invalid booking fields", () => { + expect(getPhoneFieldsForSeatedEvent(null)).toBe(null); + expect(getPhoneFieldsForSeatedEvent("invalid")).toBe(null); + }); + + it("should use field name as label if label is not provided", () => { + const bookingFields = [{ name: "customPhone", type: "phone" }]; + + const result = getPhoneFieldsForSeatedEvent(bookingFields); + + expect(result).toEqual([{ name: "customPhone", label: "customPhone" }]); + }); + + it("snapshot: phone fields extraction for seated events", () => { + const bookingFields = [ + { name: "primaryPhone", type: "phone", label: "Primary Phone Number" }, + { name: "secondaryPhone", type: "phone", label: "Secondary Phone" }, + { name: "attendeePhoneNumber", type: "phone", label: "System Phone" }, + { name: "smsReminderNumber", type: "phone", label: "SMS" }, + { name: "aiAgentCallPhoneNumber", type: "phone", label: "AI Phone" }, + { name: "company", type: "text", label: "Company Name" }, + { name: "notes", type: "textarea", label: "Notes" }, + ]; + + expect(getPhoneFieldsForSeatedEvent(bookingFields)).toMatchSnapshot(); + }); + }); + + describe("getAllFieldsForNonSeatedEvent", () => { + it("should extract all non-system fields and track phone fields", () => { + const bookingFields = [ + { name: "name", type: "text", label: "Name" }, + { name: "email", type: "email", label: "Email" }, + { name: "customPhone", type: "phone", label: "Phone" }, + { name: "company", type: "text", label: "Company" }, + { name: "howDidYouHear", type: "select", label: "How did you hear about us?" }, + ]; + + const result = getAllFieldsForNonSeatedEvent(bookingFields); + + expect(result).toEqual({ + fields: [ + { name: "customPhone", label: "Phone" }, + { name: "company", label: "Company" }, + { name: "howDidYouHear", label: "How did you hear about us?" }, + ], + phoneFieldNames: new Set(["customPhone"]), + }); + }); + + it("should return null for invalid booking fields", () => { + expect(getAllFieldsForNonSeatedEvent(null)).toBe(null); + expect(getAllFieldsForNonSeatedEvent("invalid")).toBe(null); + }); + + it("snapshot: all fields extraction for non-seated events", () => { + const bookingFields = [ + { name: "name", type: "text", label: "Your Name" }, + { name: "email", type: "email", label: "Email Address" }, + { name: "location", type: "text", label: "Location" }, + { name: "customPhone", type: "phone", label: "Contact Phone" }, + { name: "company", type: "text", label: "Company Name" }, + { name: "jobTitle", type: "text", label: "Job Title" }, + { name: "budget", type: "number", label: "Budget" }, + { name: "interests", type: "multiselect", label: "Interests" }, + { name: "website", type: "url", label: "Website URL" }, + { name: "agreeToTerms", type: "checkbox", label: "I agree to terms" }, + { name: "additionalNotes", type: "textarea", label: "Additional Notes" }, + ]; + + const result = getAllFieldsForNonSeatedEvent(bookingFields); + expect({ + fields: result?.fields, + phoneFieldNames: Array.from(result?.phoneFieldNames || []), + }).toMatchSnapshot(); + }); + }); + + describe("processBookingAttendees", () => { + const createBooking = (overrides: Partial = {}): BookingWithAttendees => ({ + uid: "test-uid", + eventTypeId: 1, + attendees: [ + { name: "John Doe", email: "john@example.com", phoneNumber: null, noShow: false }, + ], + seatsReferences: [], + responses: {}, + eventType: { bookingFields: [] }, + ...overrides, + }); + + it("should format attendees correctly", () => { + const booking = createBooking({ + attendees: [ + { name: "John Doe", email: "john@example.com", phoneNumber: "+1234567890", noShow: false }, + { name: "Jane Smith", email: "jane@example.com", phoneNumber: null, noShow: false }, + ], + }); + + const result = processBookingAttendees(booking, null, null, false); + + expect(result.attendeeList).toEqual([ + "John Doe (john@example.com)", + "Jane Smith (jane@example.com)", + ]); + expect(result.attendeePhoneNumbers).toEqual(["+1234567890", null]); + }); + + it("should track no-show attendees", () => { + const booking = createBooking({ + attendees: [ + { name: "John Doe", email: "john@example.com", phoneNumber: null, noShow: true }, + { name: "Jane Smith", email: "jane@example.com", phoneNumber: null, noShow: false }, + { name: "Bob Wilson", email: "bob@example.com", phoneNumber: null, noShow: true }, + ], + }); + + const result = processBookingAttendees(booking, null, null, false); + + expect(result.noShowGuests).toBe("John Doe (john@example.com); Bob Wilson (bob@example.com)"); + expect(result.noShowGuestsCount).toBe(2); + }); + + it("should use seat references for seated events", () => { + const booking = createBooking({ + attendees: [{ name: "Should Not Use", email: "no@example.com", phoneNumber: null, noShow: false }], + seatsReferences: [ + { attendee: { name: "Seat 1", email: "seat1@example.com", phoneNumber: "+111", noShow: false } }, + { attendee: { name: "Seat 2", email: "seat2@example.com", phoneNumber: "+222", noShow: true } }, + ], + }); + + const result = processBookingAttendees(booking, null, null, true); + + expect(result.attendeeList).toEqual([ + "Seat 1 (seat1@example.com)", + "Seat 2 (seat2@example.com)", + ]); + expect(result.noShowGuests).toBe("Seat 2 (seat2@example.com)"); + }); + + it("should extract booking question responses", () => { + const booking = createBooking({ + responses: { + company: "Acme Inc", + budget: 5000, + interests: ["marketing", "sales"], + }, + }); + + const bookingFields = [ + { name: "company", label: "Company Name" }, + { name: "budget", label: "Budget" }, + { name: "interests", label: "Interests" }, + ]; + + const result = processBookingAttendees(booking, bookingFields, null, false); + + expect(result.bookingQuestionResponses).toEqual({ + "Company Name": "Acme Inc", + Budget: "5000", + Interests: "marketing, sales", + }); + }); + + it("should use system phone as fallback for attendee phone", () => { + const booking = createBooking({ + attendees: [ + { name: "John Doe", email: "john@example.com", phoneNumber: null, noShow: false }, + ], + responses: { + attendeePhoneNumber: "+1234567890", + }, + }); + + const result = processBookingAttendees(booking, null, null, false); + + expect(result.attendeePhoneNumbers).toEqual(["+1234567890"]); + }); + + it("should use custom phone field as fallback when system phone not available", () => { + const booking = createBooking({ + attendees: [ + { name: "John Doe", email: "john@example.com", phoneNumber: null, noShow: false }, + ], + responses: { + customPhone: "+9876543210", + }, + }); + + const bookingFields = [{ name: "customPhone", label: "Custom Phone" }]; + const phoneFieldNames = new Set(["customPhone"]); + + const result = processBookingAttendees(booking, bookingFields, phoneFieldNames, false); + + expect(result.attendeePhoneNumbers).toEqual(["+9876543210"]); + }); + + it("snapshot: complete booking processing", () => { + const booking = createBooking({ + attendees: [ + { name: "Alice Johnson", email: "alice@example.com", phoneNumber: "+1111111111", noShow: false }, + { name: "Bob Smith", email: "bob@example.com", phoneNumber: null, noShow: true }, + { name: "Carol White", email: "carol@example.com", phoneNumber: null, noShow: false }, + ], + responses: { + attendeePhoneNumber: "+0000000000", + company: "Tech Corp", + jobTitle: "Engineer", + budget: 10000, + interests: ["AI", "Cloud"], + agreeToTerms: true, + }, + }); + + const bookingFields = [ + { name: "company", label: "Company" }, + { name: "jobTitle", label: "Job Title" }, + { name: "budget", label: "Budget" }, + { name: "interests", label: "Interests" }, + { name: "agreeToTerms", label: "Agreed to Terms" }, + ]; + + const result = processBookingAttendees(booking, bookingFields, null, false); + + expect(result).toMatchSnapshot(); + }); + }); + + describe("processBookingsForCsv", () => { + it("should process multiple bookings and track max attendees", () => { + const bookings: BookingWithAttendees[] = [ + { + uid: "booking-1", + eventTypeId: 1, + attendees: [ + { name: "A1", email: "a1@test.com", phoneNumber: null, noShow: false }, + ], + seatsReferences: [], + responses: {}, + eventType: { bookingFields: [] }, + }, + { + uid: "booking-2", + eventTypeId: 1, + attendees: [ + { name: "B1", email: "b1@test.com", phoneNumber: null, noShow: false }, + { name: "B2", email: "b2@test.com", phoneNumber: null, noShow: false }, + { name: "B3", email: "b3@test.com", phoneNumber: null, noShow: false }, + ], + seatsReferences: [], + responses: {}, + eventType: { bookingFields: [] }, + }, + ]; + + const result = processBookingsForCsv(bookings); + + expect(result.maxAttendees).toBe(3); + expect(result.bookingMap.size).toBe(2); + }); + + it("should collect all booking question labels across bookings", () => { + const bookings: BookingWithAttendees[] = [ + { + uid: "booking-1", + eventTypeId: 1, + attendees: [{ name: "A", email: "a@test.com", phoneNumber: null, noShow: false }], + seatsReferences: [], + responses: { company: "Company A" }, + eventType: { + bookingFields: [{ name: "company", type: "text", label: "Company" }], + }, + }, + { + uid: "booking-2", + eventTypeId: 2, + attendees: [{ name: "B", email: "b@test.com", phoneNumber: null, noShow: false }], + seatsReferences: [], + responses: { department: "Engineering" }, + eventType: { + bookingFields: [{ name: "department", type: "text", label: "Department" }], + }, + }, + ]; + + const result = processBookingsForCsv(bookings); + + expect(result.allBookingQuestionLabels).toContain("Company"); + expect(result.allBookingQuestionLabels).toContain("Department"); + }); + + it("snapshot: processing multiple bookings with different event types", () => { + const bookings: BookingWithAttendees[] = [ + { + uid: "non-seated-booking", + eventTypeId: 1, + attendees: [ + { name: "John Doe", email: "john@example.com", phoneNumber: "+1234567890", noShow: false }, + { name: "Jane Smith", email: "jane@example.com", phoneNumber: null, noShow: true }, + ], + seatsReferences: [], + responses: { + company: "Acme Inc", + howDidYouHear: "Google", + customPhone: "+9999999999", + }, + eventType: { + bookingFields: [ + { name: "company", type: "text", label: "Company Name" }, + { name: "howDidYouHear", type: "select", label: "How did you hear about us?" }, + { name: "customPhone", type: "phone", label: "Contact Phone" }, + ], + }, + }, + { + uid: "seated-booking", + eventTypeId: 2, + attendees: [], + seatsReferences: [ + { attendee: { name: "Seat A", email: "seata@example.com", phoneNumber: null, noShow: false } }, + { attendee: { name: "Seat B", email: "seatb@example.com", phoneNumber: null, noShow: false } }, + ], + responses: { + emergencyPhone: "+5555555555", + }, + eventType: { + bookingFields: [ + { name: "emergencyPhone", type: "phone", label: "Emergency Contact" }, + { name: "dietaryRestrictions", type: "text", label: "Dietary Restrictions" }, + ], + }, + }, + ]; + + const result = processBookingsForCsv(bookings); + + expect({ + maxAttendees: result.maxAttendees, + allBookingQuestionLabels: Array.from(result.allBookingQuestionLabels), + bookings: Array.from(result.bookingMap.entries()), + }).toMatchSnapshot(); + }); + }); + + describe("formatCsvRow", () => { + const createBookingTimeStatus = (overrides: Partial = {}): BookingTimeStatusData => ({ + id: 1, + uid: "test-uid", + title: "Test Meeting", + createdAt: new Date("2024-01-15T10:00:00Z"), + timeStatus: "completed", + eventTypeId: 1, + eventLength: 30, + startTime: new Date("2024-01-15T14:00:00Z"), + endTime: new Date("2024-01-15T14:30:00Z"), + paid: false, + userEmail: "host@example.com", + userUsername: "host", + rating: 5, + ratingFeedback: "Great meeting!", + noShowHost: false, + ...overrides, + }); + + it("should format dates correctly for timezone", () => { + const bookingTimeStatus = createBookingTimeStatus(); + + const result = formatCsvRow( + bookingTimeStatus, + null, + 0, + new Set(), + "America/New_York" + ); + + expect(result.createdAt).toBe("2024-01-15T10:00:00.000Z"); + expect(result.createdAt_date).toBe("2024-01-15"); + expect(result.createdAt_time).toBe("05:00:00"); + }); + + it("should include attendee columns based on maxAttendees", () => { + const bookingTimeStatus = createBookingTimeStatus(); + const processedData = { + noShowGuests: null, + noShowGuestsCount: 0, + attendeeList: ["John (john@test.com)", "Jane (jane@test.com)"], + attendeePhoneNumbers: ["+111", "+222"], + bookingQuestionResponses: {}, + }; + + const result = formatCsvRow( + bookingTimeStatus, + processedData, + 3, + new Set(), + "UTC" + ); + + expect(result.attendee1).toBe("John (john@test.com)"); + expect(result.attendeePhone1).toBe("+111"); + expect(result.attendee2).toBe("Jane (jane@test.com)"); + expect(result.attendeePhone2).toBe("+222"); + expect(result.attendee3).toBe(null); + expect(result.attendeePhone3).toBe(null); + }); + + it("should include booking question responses", () => { + const bookingTimeStatus = createBookingTimeStatus(); + const processedData = { + noShowGuests: null, + noShowGuestsCount: 0, + attendeeList: [], + attendeePhoneNumbers: [], + bookingQuestionResponses: { + Company: "Acme Inc", + Budget: "5000", + }, + }; + + const result = formatCsvRow( + bookingTimeStatus, + processedData, + 0, + new Set(["Company", "Budget", "Notes"]), + "UTC" + ); + + expect(result.Company).toBe("Acme Inc"); + expect(result.Budget).toBe("5000"); + expect(result.Notes).toBe(null); + }); + + it("snapshot: complete CSV row formatting", () => { + const bookingTimeStatus = createBookingTimeStatus({ + id: 123, + uid: "booking-abc-123", + title: "Strategy Meeting", + createdAt: new Date("2024-03-20T09:30:00Z"), + timeStatus: "completed", + eventTypeId: 5, + eventLength: 60, + startTime: new Date("2024-03-20T15:00:00Z"), + endTime: new Date("2024-03-20T16:00:00Z"), + paid: true, + userEmail: "manager@company.com", + userUsername: "manager", + rating: 4, + ratingFeedback: "Very productive", + noShowHost: false, + }); + + const processedData = { + noShowGuests: "Late Person (late@example.com)", + noShowGuestsCount: 1, + attendeeList: [ + "Alice Johnson (alice@example.com)", + "Bob Smith (bob@example.com)", + "Late Person (late@example.com)", + ], + attendeePhoneNumbers: ["+1111111111", "+2222222222", null], + bookingQuestionResponses: { + Company: "Tech Corp", + "Job Title": "Senior Engineer", + Budget: "50000", + "Meeting Purpose": "Quarterly Review", + }, + }; + + const allLabels = new Set([ + "Company", + "Job Title", + "Budget", + "Meeting Purpose", + "Additional Notes", + ]); + + const result = formatCsvRow( + bookingTimeStatus, + processedData, + 3, + allLabels, + "America/Los_Angeles" + ); + + expect(result).toMatchSnapshot(); + }); + }); + + describe("transformBookingsForCsv", () => { + it("snapshot: complete transformation pipeline", () => { + const csvData: BookingTimeStatusData[] = [ + { + id: 1, + uid: "booking-1", + title: "Sales Call", + createdAt: new Date("2024-02-01T08:00:00Z"), + timeStatus: "completed", + eventTypeId: 1, + eventLength: 30, + startTime: new Date("2024-02-01T10:00:00Z"), + endTime: new Date("2024-02-01T10:30:00Z"), + paid: false, + userEmail: "sales@company.com", + userUsername: "sales", + rating: 5, + ratingFeedback: "Excellent", + noShowHost: false, + }, + { + id: 2, + uid: "booking-2", + title: "Team Workshop", + createdAt: new Date("2024-02-02T09:00:00Z"), + timeStatus: "completed", + eventTypeId: 2, + eventLength: 120, + startTime: new Date("2024-02-02T14:00:00Z"), + endTime: new Date("2024-02-02T16:00:00Z"), + paid: true, + userEmail: "trainer@company.com", + userUsername: "trainer", + rating: null, + ratingFeedback: null, + noShowHost: false, + }, + { + id: 3, + uid: null, + title: "Orphan Booking", + createdAt: new Date("2024-02-03T10:00:00Z"), + timeStatus: "upcoming", + eventTypeId: null, + eventLength: 45, + startTime: new Date("2024-02-03T15:00:00Z"), + endTime: new Date("2024-02-03T15:45:00Z"), + paid: false, + userEmail: "user@company.com", + userUsername: "user", + rating: null, + ratingFeedback: null, + noShowHost: false, + }, + ]; + + const bookings: BookingWithAttendees[] = [ + { + uid: "booking-1", + eventTypeId: 1, + attendees: [ + { name: "Client A", email: "clienta@example.com", phoneNumber: "+1234567890", noShow: false }, + ], + seatsReferences: [], + responses: { + company: "Client Corp", + budget: 25000, + timeline: "Q2 2024", + }, + eventType: { + bookingFields: [ + { name: "company", type: "text", label: "Company" }, + { name: "budget", type: "number", label: "Budget" }, + { name: "timeline", type: "text", label: "Timeline" }, + ], + }, + }, + { + uid: "booking-2", + eventTypeId: 2, + attendees: [], + seatsReferences: [ + { attendee: { name: "Participant 1", email: "p1@example.com", phoneNumber: null, noShow: false } }, + { attendee: { name: "Participant 2", email: "p2@example.com", phoneNumber: null, noShow: true } }, + { attendee: { name: "Participant 3", email: "p3@example.com", phoneNumber: "+9876543210", noShow: false } }, + ], + responses: { + emergencyContact: "+5555555555", + }, + eventType: { + bookingFields: [ + { name: "emergencyContact", type: "phone", label: "Emergency Contact" }, + ], + }, + }, + ]; + + const result = transformBookingsForCsv(csvData, bookings, "UTC"); + + expect(result).toMatchSnapshot(); + }); + + it("should handle empty bookings array", () => { + const csvData: BookingTimeStatusData[] = [ + { + id: 1, + uid: "booking-1", + title: "Test", + createdAt: new Date("2024-01-01T00:00:00Z"), + timeStatus: "completed", + eventTypeId: 1, + eventLength: 30, + startTime: new Date("2024-01-01T10:00:00Z"), + endTime: new Date("2024-01-01T10:30:00Z"), + paid: false, + userEmail: "test@test.com", + userUsername: "test", + rating: null, + ratingFeedback: null, + noShowHost: false, + }, + ]; + + const result = transformBookingsForCsv(csvData, [], "UTC"); + + expect(result).toHaveLength(1); + expect(result[0].noShowGuests).toBe(null); + expect(result[0].noShowGuestsCount).toBe(0); + }); + + it("should handle bookings with no matching csvData entries", () => { + const csvData: BookingTimeStatusData[] = []; + const bookings: BookingWithAttendees[] = [ + { + uid: "booking-1", + eventTypeId: 1, + attendees: [{ name: "Test", email: "test@test.com", phoneNumber: null, noShow: false }], + seatsReferences: [], + responses: {}, + eventType: { bookingFields: [] }, + }, + ]; + + const result = transformBookingsForCsv(csvData, bookings, "UTC"); + + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/packages/features/insights/services/csvDataTransformer.ts b/packages/features/insights/services/csvDataTransformer.ts new file mode 100644 index 0000000000..088fb3f1e4 --- /dev/null +++ b/packages/features/insights/services/csvDataTransformer.ts @@ -0,0 +1,299 @@ +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; +}; + +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< + number, + { fields: BookingFieldInfo[]; phoneFieldNames: Set } + >(); + 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 transformBookingsForCsv( + csvData: BookingTimeStatusData[], + bookings: BookingWithAttendees[], + timeZone: string +): Record[] { + const { bookingMap, maxAttendees, allBookingQuestionLabels } = processBookingsForCsv(bookings); + + return csvData.map((bookingTimeStatus) => { + const processedData = bookingTimeStatus.uid ? bookingMap.get(bookingTimeStatus.uid) : null; + return formatCsvRow( + bookingTimeStatus, + processedData || null, + maxAttendees, + allBookingQuestionLabels, + timeZone + ); + }); +}