Files
Hariom BalharaGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
e04a394e1c fix: (booking-audit) Remove IS_PRODUCTION gate and add feature flag check in producer (#26524)
* fix: remove IS_PRODUCTION gate from BookingAuditProducer

Remove the IS_PRODUCTION check that was preventing booking audits from
being queued in production. Audits are still properly gated by:

1. Organization check: Audits are skipped for non-organization bookings
   (organizationId === null)
2. Feature flag: The BookingAuditTaskConsumer checks if the 'booking-audit'
   feature is enabled for the organization via featuresRepository.checkIfTeamHasFeature()

The IS_PRODUCTION gate was intentionally added to prevent logs from being
created in production while the action data versioning was being actively
reviewed and finalized. Without proper versioning handling, the Booking
History UI could crash when encountering unversioned data. Now that the
versioning system is in place, this gate can be safely removed.

Co-Authored-By: [email protected] <[email protected]>

* fix: revert formatting, keep only IS_PRODUCTION removal

Reverts the unintended formatting changes from the previous commit.
Only removes the IS_PRODUCTION gate without changing indentation.

Co-Authored-By: [email protected] <[email protected]>

* feat: add booking-audit feature flag check in producer to avoid unnecessary task creation

- Query booking-audit and booking-email-sms-tasker flags in parallel before fireBookingEvents
- Pass isBookingAuditEnabled through BookingEventHandler to producer's queueTask method
- Add conditional check in queueTask with debug log when skipping audit
- Reuse pre-queried isBookingEmailSmsTaskerEnabled flag instead of querying again

Co-Authored-By: [email protected] <[email protected]>

* feat: make isBookingAuditEnabled required and pass it through all booking audit flows

- Make isBookingAuditEnabled a required property in BookingAuditProducerService interface
- Update all BookingEventHandler methods to require isBookingAuditEnabled
- Add feature flag check in all flows that call booking audit:
  - handleSeats (seat booking/rescheduling)
  - RecurringBookingService (bulk bookings)
  - handleCancelBooking (booking cancellation)
  - handleConfirmation (booking acceptance)
  - roundRobinReassignment (automatic reassignment)
  - roundRobinManualReassignment (manual reassignment)
  - trpc handlers: addGuests, confirm, editLocation, requestReschedule
- Skip queueing audit tasks when feature is disabled with debug logging

Co-Authored-By: [email protected] <[email protected]>

* fix: add featuresRepository dependency to RecurringBookingService DI module

Co-Authored-By: [email protected] <[email protected]>

* refactor: make isBookingAuditEnabled optional for non-main flows

Keep feature flag check only in main flows (handleSeats, RegularBookingService, RecurringBookingService) which are frequently triggered. For other flows (handleCancelBooking, handleConfirmation, roundRobinReassignment, etc.), rely on the existing consumer-level check.

Changes:
- Revert feature flag check from non-main flows
- Make isBookingAuditEnabled optional in interface for non-main flow methods
- Keep isBookingAuditEnabled required for main flow methods (queueCreatedAudit, queueRescheduledAudit, queueSeatBookedAudit, queueSeatRescheduledAudit, queueBulkCreatedAudit, queueBulkRescheduledAudit)
- Update BookingEventHandlerService to use required params for main flows and optional for non-main flows

Co-Authored-By: [email protected] <[email protected]>

* refactor: remove isBookingAuditEnabled from non-main flow methods

Co-Authored-By: [email protected] <[email protected]>

* feat: make isBookingAuditEnabled required in all BookingEventHandlerService methods

- Add isBookingAuditEnabled as required parameter in all BookingAuditProducerService interface methods
- Update BookingAuditTaskerProducerService to use simplified check (!params.isBookingAuditEnabled)
- Update BookingEventHandlerService to require isBookingAuditEnabled in all methods
- Update all callers to query booking-audit feature flag and pass isBookingAuditEnabled:
  - handleCancelBooking
  - handleConfirmation
  - roundRobinReassignment
  - roundRobinManualReassignment
  - addGuests.handler
  - confirm.handler
  - editLocation.handler
  - requestReschedule.handler
- Inject featuresRepository in API V2's booking-location.service.ts

Co-Authored-By: [email protected] <[email protected]>

* test: update roundRobinReassignment tests to include isBookingAuditEnabled

Co-Authored-By: [email protected] <[email protected]>

* test: update roundRobinManualReassignment tests to include isBookingAuditEnabled

Co-Authored-By: [email protected] <[email protected]>

* fix: inject featuresRepository in API V2 RecurringBookingService

Co-Authored-By: [email protected] <[email protected]>

* fix: import PrismaWorkerModule for PrismaFeaturesRepository dependency

Co-Authored-By: [email protected] <[email protected]>

* fix: use booking's organization for feature flag check in addGuests handler

Use booking.user?.profiles?.[0]?.organizationId instead of user.organizationId
to check the booking-audit feature flag. This ensures the feature flag is
checked against the booking's organization rather than the actor's organization,
which is consistent with other handlers in this PR.

Addresses Cubic AI review feedback (confidence 9/10).

Co-Authored-By: unknown <>

* Add comment

* fix: use user.organizationId for feature flag check in addGuests handler

Co-Authored-By: [email protected] <[email protected]>

* fix: use booking's organizationId for feature flag check in addGuests handler

Co-Authored-By: [email protected] <[email protected]>

* fix: revert to user.organizationId for feature flag check in addGuests handler

Co-Authored-By: [email protected] <[email protected]>

* fix: add isBookingAuditEnabled to onNoShowUpdated calls after main merge

Co-Authored-By: [email protected] <[email protected]>

* fix: add isBookingAuditEnabled to onNoShowUpdated calls and update tests

Co-Authored-By: [email protected] <[email protected]>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-09 09:25:33 -03:00

373 lines
13 KiB
TypeScript

import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils";
import dayjs from "@calcom/dayjs";
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
import { getBookingEventHandlerService } from "@calcom/features/bookings/di/BookingEventHandlerService.container";
import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository";
import { BookingEmailSmsHandler } from "@calcom/features/bookings/lib/BookingEmailSmsHandler";
import EventManager from "@calcom/features/bookings/lib/EventManager";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { BookingResponses } from "@calcom/prisma/zod-utils";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TAddGuestsInputSchema } from "./addGuests.schema";
type TUser = Pick<NonNullable<TrpcSessionUser>, "id" | "email" | "organizationId" | "uuid"> &
Partial<Pick<NonNullable<TrpcSessionUser>, "profile">>;
type AddGuestsOptions = {
ctx: {
user: TUser;
};
input: TAddGuestsInputSchema;
emailsEnabled?: boolean;
actionSource: ActionSource;
};
type Booking = NonNullable<Awaited<ReturnType<BookingRepository["findByIdIncludeDestinationCalendar"]>>>;
type OrganizerData = Awaited<ReturnType<typeof getOrganizerData>>;
export const addGuestsHandler = async ({
ctx,
input,
emailsEnabled = true,
actionSource,
}: AddGuestsOptions) => {
const { user } = ctx;
const { bookingId, guests } = input;
const booking = await getBooking(bookingId);
await validateUserPermissions(booking, user);
validateGuestsFieldEnabled(booking);
const organizer = await getOrganizerData(booking.userId);
const uniqueGuests = await sanitizeAndFilterGuests(guests, booking);
const newGuestsDetails = uniqueGuests.map((guest) => ({
name: guest.name || "",
email: guest.email,
timeZone: guest.timeZone || organizer.timeZone,
locale: guest.language || organizer.locale,
}));
const uniqueGuestEmails = uniqueGuests.map((guest) => guest.email);
const bookingAttendees = await updateBookingAttendees(
bookingId,
newGuestsDetails,
uniqueGuestEmails,
booking
);
// Capture new attendee emails after update for audit logging
const newAttendeeEmails = bookingAttendees.attendees.map((attendee) => attendee.email);
const attendeesList = await prepareAttendeesList(bookingAttendees.attendees);
const evt = await buildCalendarEvent(booking, organizer, attendeesList);
await updateCalendarEvent(booking, evt);
if (emailsEnabled) {
await sendGuestNotifications(evt, booking, uniqueGuestEmails);
}
const bookingEventHandlerService = getBookingEventHandlerService();
const featuresRepository = getFeaturesRepository();
const organizationId = user.organizationId ?? null;
const isBookingAuditEnabled = organizationId
? await featuresRepository.checkIfTeamHasFeature(organizationId, "booking-audit")
: false;
await bookingEventHandlerService.onAttendeeAdded({
bookingUid: booking.uid,
actor: makeUserActor(user.uuid),
organizationId,
source: actionSource,
auditData: {
added: uniqueGuestEmails,
},
isBookingAuditEnabled,
});
return { message: "Guests added" };
};
async function getBooking(bookingId: number) {
const bookingRepository = new BookingRepository(prisma);
const booking = await bookingRepository.findByIdIncludeDestinationCalendar(bookingId);
if (!booking || !booking.user) {
throw new TRPCError({ code: "NOT_FOUND", message: "booking_not_found" });
}
return booking;
}
async function validateUserPermissions(booking: Booking, user: TUser): Promise<void> {
const isOrganizer = booking.userId === user.id;
const isAttendee = !!booking.attendees.find((attendee) => attendee.email === user.email);
let hasBookingUpdatePermission = false;
if (booking.eventType?.teamId) {
const permissionCheckService = new PermissionCheckService();
hasBookingUpdatePermission = await permissionCheckService.checkPermission({
userId: user.id,
teamId: booking.eventType?.teamId,
permission: "booking.update",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
}
if (!hasBookingUpdatePermission && !isOrganizer && !isAttendee) {
throw new TRPCError({ code: "FORBIDDEN", message: "you_do_not_have_permission" });
}
}
function validateGuestsFieldEnabled(booking: Booking): void {
const parsedBookingFields = booking?.eventType?.bookingFields
? eventTypeBookingFields.parse(booking.eventType.bookingFields)
: [];
const guestsBookingField = parsedBookingFields.find((field) => field.name === "guests");
if (guestsBookingField?.hidden) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Cannot add guests to this booking. The guests field is disabled for event type "${booking?.eventType?.title}" (ID: ${booking?.eventTypeId}). Please contact the event organizer to enable guest additions.`,
});
}
}
async function getOrganizerData(userId: number | null) {
if (!userId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "User not found" });
}
return await prisma.user.findUniqueOrThrow({
where: {
id: userId,
},
select: {
name: true,
email: true,
timeZone: true,
locale: true,
},
});
}
function deduplicateGuestEmails(guests: string[]): string[] {
const seenBaseEmails = new Set<string>();
return guests.filter((guest) => {
const baseEmail = extractBaseEmail(guest).toLowerCase();
if (seenBaseEmails.has(baseEmail)) {
return false;
}
seenBaseEmails.add(baseEmail);
return true;
});
}
function getBlacklistedEmails(): string[] {
return process.env.BLACKLISTED_GUEST_EMAILS
? process.env.BLACKLISTED_GUEST_EMAILS.split(",").map((email) => email.toLowerCase())
: [];
}
async function getEmailVerificationRequirements(guestEmails: string[]): Promise<Map<string, boolean>> {
const userRepo = new UserRepository(prisma);
const guestUsers = await userRepo.findManyByEmailsWithEmailVerificationSettings({ emails: guestEmails });
const emailToRequiresVerification = new Map<string, boolean>();
for (const user of guestUsers) {
const matchedBase = extractBaseEmail(user.matchedEmail ?? user.email).toLowerCase();
emailToRequiresVerification.set(matchedBase, user.requiresBookerEmailVerification === true);
}
return emailToRequiresVerification;
}
async function sanitizeAndFilterGuests(
guests: Array<{
email: string;
name?: string;
timeZone?: string;
phoneNumber?: string;
language?: string;
}>,
booking: Booking
): Promise<
Array<{
email: string;
name?: string;
timeZone?: string;
phoneNumber?: string;
language?: string;
}>
> {
const guestEmails = guests.map((guest) => guest.email);
const deduplicatedGuests = deduplicateGuestEmails(guestEmails);
const blacklistedGuestEmails = getBlacklistedEmails();
const guestEmailsLowerCase = deduplicatedGuests.map((email) => extractBaseEmail(email).toLowerCase());
const emailToRequiresVerification = await getEmailVerificationRequirements(guestEmailsLowerCase);
// Create a map of email to guest object for easy lookup
const emailToGuestMap = new Map(
guests.map((guest) => [extractBaseEmail(guest.email).toLowerCase(), guest])
);
const uniqueGuestEmails = deduplicatedGuests.filter((email) => {
const baseGuestEmail = extractBaseEmail(email).toLowerCase();
return (
!booking.attendees.some(
(attendee) => extractBaseEmail(attendee.email).toLowerCase() === baseGuestEmail
) &&
!blacklistedGuestEmails.includes(baseGuestEmail) &&
!emailToRequiresVerification.get(baseGuestEmail)
);
});
if (uniqueGuestEmails.length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "emails_must_be_unique_valid" });
}
// Return the full guest objects for unique emails
return uniqueGuestEmails
.map((email) => emailToGuestMap.get(extractBaseEmail(email).toLowerCase()))
.filter((guest): guest is NonNullable<typeof guest> => guest !== undefined);
}
async function updateBookingAttendees(
bookingId: number,
newAttendees: { name: string; email: string; timeZone: string; locale: string | null }[],
uniqueGuestEmails: string[],
booking: Booking
) {
const bookingResponses = booking.responses as BookingResponses;
const bookingRepository = new BookingRepository(prisma);
return await bookingRepository.updateBookingAttendees({
bookingId,
newAttendees,
updatedResponses: {
...bookingResponses,
guests: [...(bookingResponses?.guests || []), ...uniqueGuestEmails],
},
});
}
async function prepareAttendeesList(attendees: Booking["attendees"]) {
const attendeesListPromises = attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
return await Promise.all(attendeesListPromises);
}
async function buildCalendarEvent(
booking: Booking,
organizer: OrganizerData,
attendeesList: Awaited<ReturnType<typeof prepareAttendeesList>>
): Promise<CalendarEvent> {
const tOrganizer = await getTranslation(organizer.locale ?? "en", "common");
const videoCallReference = booking.references.find((reference) => reference.type.includes("_video"));
const evt: CalendarEvent = {
title: booking.title || "",
type: (booking.eventType?.title as string) || booking?.title || "",
description: booking.description || "",
startTime: booking.startTime ? dayjs(booking.startTime).format() : "",
endTime: booking.endTime ? dayjs(booking.endTime).format() : "",
organizer: {
email: booking?.userPrimaryEmail ?? organizer.email,
name: organizer.name ?? "Nameless",
timeZone: organizer.timeZone,
language: { translate: tOrganizer, locale: organizer.locale ?? "en" },
},
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
attendees: attendeesList,
uid: booking.uid,
iCalUID: booking.iCalUID,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
location: booking.location,
destinationCalendar: booking?.destinationCalendar
? [booking?.destinationCalendar]
: booking?.user?.destinationCalendar
? [booking?.user?.destinationCalendar]
: [],
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
customReplyToEmail: booking.eventType?.customReplyToEmail,
organizationId: booking.user?.profiles?.[0]?.organizationId ?? null,
};
if (videoCallReference) {
evt.videoCallData = {
type: videoCallReference.type,
id: videoCallReference.meetingId,
password: videoCallReference?.meetingPassword,
url: videoCallReference.meetingUrl,
};
}
return evt;
}
async function updateCalendarEvent(booking: Booking, evt: CalendarEvent): Promise<void> {
if (!booking.user) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Booking user not found" });
}
const credentials = await getUsersCredentialsIncludeServiceAccountKey(booking.user);
const eventManager = new EventManager({
...booking.user,
credentials: [...credentials],
});
await eventManager.updateCalendarAttendees(evt, booking);
}
async function sendGuestNotifications(
evt: CalendarEvent,
booking: Booking,
uniqueGuests: string[]
): Promise<void> {
const emailsAndSmsHandler = new BookingEmailSmsHandler({
logger: logger,
});
await emailsAndSmsHandler.handleAddGuests({
evt,
eventType: {
metadata: eventTypeMetaDataSchemaWithTypedApps.parse(booking?.eventType?.metadata),
schedulingType: booking.eventType?.schedulingType || null,
},
newGuests: uniqueGuests,
});
}