Files
calendar/packages/features/insights/services/csvDataTransformer.ts
T
98b6d63164 refactor: apply biome formatting to packages/features (#27844)
* refactor: apply biome formatting to packages/features (batch 1 - small subdirs)

Format small subdirectories in packages/features: di, flags, holidays, oauth,
settings, users, assignment-reason, selectedCalendar, hashedLink, host, form,
form-builder, availability, data-table, pbac, schedules, troubleshooter,
eventtypes, calendar-subscription, and root-level files.

Also includes straggler apps/web BookEventForm.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 2 - medium subdirs)

Format medium subdirectories in packages/features: auth, credentials,
calendars, routing-forms, routing-trace, attributes, watchlist, calAIPhone,
tasker, and webhooks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 3 - bookings + insights)

Format bookings and insights subdirectories in packages/features.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 4 - ee)

Format packages/features/ee subdirectory covering billing, workflows,
organizations, teams, managed-event-types, round-robin, dsync,
integration-attribute-sync, and payments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 5 - booking-audit part 1)

Format booking-audit di, actions, common, dto, repository, and types
subdirectories in packages/features/booking-audit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: apply biome formatting to packages/features (batch 6 - booking-audit part 2)

Format booking-audit service subdirectory in packages/features/booking-audit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:47:14 +01:00

316 lines
10 KiB
TypeScript

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;
tracking: {
utm_source: string | null;
utm_medium: string | null;
utm_campaign: string | null;
utm_term: string | null;
utm_content: string | null;
} | 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 getUtmDataForBooking(
booking: BookingWithAttendees | undefined
): Record<string, string> {
return {
utm_source: booking?.tracking?.utm_source || "",
utm_medium: booking?.tracking?.utm_medium || "",
utm_campaign: booking?.tracking?.utm_campaign || "",
utm_term: booking?.tracking?.utm_term || "",
utm_content: booking?.tracking?.utm_content || "",
};
}
export function transformBookingsForCsv(
csvData: BookingTimeStatusData[],
bookings: BookingWithAttendees[],
timeZone: string
): Record<string, unknown>[] {
const { bookingMap, maxAttendees, allBookingQuestionLabels } = processBookingsForCsv(bookings);
const bookingsByUid = new Map(bookings.map((b) => [b.uid, b]));
return csvData.map((bookingTimeStatus) => {
const processedData = bookingTimeStatus.uid ? bookingMap.get(bookingTimeStatus.uid) : null;
const booking = bookingTimeStatus.uid ? bookingsByUid.get(bookingTimeStatus.uid) : undefined;
const row = formatCsvRow(
bookingTimeStatus,
processedData || null,
maxAttendees,
allBookingQuestionLabels,
timeZone
);
return {
...row,
...getUtmDataForBooking(booking),
};
});
}