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 <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>
This commit is contained in:
4 files changed
+1393
-140
No files matched your search
@@ -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<number, { name: string; label: string }[]>();
|
||||
const allPhoneFieldLabels = new Set<string>();
|
||||
let maxAttendees = 0;
|
||||
const finalBookingMap = new Map<
|
||||
string,
|
||||
{
|
||||
noShowGuests: string | null;
|
||||
noShowGuestsCount: number;
|
||||
attendeeList: string[];
|
||||
attendeePhoneNumbers: (string | null)[];
|
||||
phoneQuestionResponses: Record<string, string | null>;
|
||||
}
|
||||
>();
|
||||
|
||||
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<string, string | null> = {};
|
||||
let systemPhoneValue: string | null = null;
|
||||
|
||||
if (booking.responses && typeof booking.responses === "object") {
|
||||
const responses = booking.responses as Record<string, unknown>;
|
||||
|
||||
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<string, unknown> = {
|
||||
...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 };
|
||||
}
|
||||
|
||||
+326
@@ -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",
|
||||
},
|
||||
]
|
||||
`;
|
||||
@@ -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> = {}): 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> = {}): 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | null>;
|
||||
};
|
||||
|
||||
export type BookingFieldInfo = {
|
||||
name: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ProcessBookingsResult = {
|
||||
bookingMap: Map<string, ProcessedBookingData>;
|
||||
maxAttendees: number;
|
||||
allBookingQuestionLabels: Set<string>;
|
||||
};
|
||||
|
||||
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<string> } | null {
|
||||
const parsed = eventTypeBookingFields.safeParse(bookingFields);
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const phoneNames = new Set<string>();
|
||||
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<string> | 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<string, string | null> = {};
|
||||
let systemPhoneValue: string | null = null;
|
||||
let firstCustomPhoneValue: string | null = null;
|
||||
|
||||
if (booking.responses && typeof booking.responses === "object") {
|
||||
const responses = booking.responses as Record<string, unknown>;
|
||||
|
||||
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<number, BookingFieldInfo[]>();
|
||||
const allFieldsCache = new Map<
|
||||
number,
|
||||
{ fields: BookingFieldInfo[]; phoneFieldNames: Set<string> }
|
||||
>();
|
||||
const allBookingQuestionLabels = new Set<string>();
|
||||
let maxAttendees = 0;
|
||||
const bookingMap = new Map<string, ProcessedBookingData>();
|
||||
|
||||
for (const booking of bookings) {
|
||||
const eventTypeId = booking.eventTypeId;
|
||||
const isSeatedEvent = booking.seatsReferences.length > 0;
|
||||
let bookingFields: BookingFieldInfo[] | null = null;
|
||||
let phoneFieldNames: Set<string> | 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<string>,
|
||||
timeZone: string
|
||||
): Record<string, unknown> {
|
||||
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<string, unknown> = {
|
||||
...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<string, unknown>[] {
|
||||
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
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user