Files
calendar/packages/features/bookings/services/BookingAttendeesService.ts
T
Rajiv SahalGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>bot_apk
3c57960b7d feat: api v2 DELETE booking attendees endpoint (#27781)
* feat: remove attendee endpoint

* fix: remove attendee email from error logs to avoid logging PII

Co-Authored-By: unknown <>

* fix: add isBookingAuditEnabled to removeAttendee handler

Align removeAttendee.handler.ts with the new onAttendeeRemoved interface
that requires isBookingAuditEnabled, following the same pattern used in
addGuests.handler.ts.

Co-Authored-By: bot_apk <apk@cognition.ai>

* style: apply biome formatting to conflict-resolved files

Co-Authored-By: bot_apk <apk@cognition.ai>

* chore: implement PR feedback

* fixup

* revert: biome formatting changes

* chore: implement feedback part 1

* chore: implement feedback part 2

* fix: await cancellation email flow to prevent uncaught promise rejections

The fire-and-forget .then() chain on prepareAttendeePerson() left
rejections from that promise uncaught. Await both prepareAttendeePerson()
and sendCancelledEmailToAttendee() so errors are properly handled.
sendCancelledEmailToAttendee() already has an internal try/catch, so
awaiting it will not cause the overall removeAttendee flow to fail on
email errors.

Addresses Cubic AI review (confidence 9/10).

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: implement feedback part 3

* chore: implement devin feedback

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: bot_apk <apk@cognition.ai>
2026-03-18 13:25:53 +02:00

201 lines
6.1 KiB
TypeScript

import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils";
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
import { BookingEmailSmsHandler } from "@calcom/features/bookings/lib/BookingEmailSmsHandler";
import type { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService";
import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import type { BookingAttendeesRemoveService } from "@calcom/features/bookings/services/BookingAttendeesRemoveService";
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger";
import type { Booking, TUser } from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler";
import {
buildCalendarEvent,
getBooking,
getOrganizerData,
prepareAttendeesList,
sanitizeAndFilterGuests,
updateBookingAttendees,
updateCalendarEvent,
validateGuestsFieldEnabled,
validateUserPermissions,
} from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler";
import type { TAddGuestsInputSchema } from "@calcom/trpc/server/routers/viewer/bookings/addGuests.schema";
import type { CalendarEvent } from "@calcom/types/Calendar";
type Attendee = TAddGuestsInputSchema["guests"][number];
type AddAttendeeInput = {
bookingId: number;
attendee: Attendee;
user: TUser;
emailsEnabled?: boolean;
actionSource: ActionSource;
};
export type CreatedAttendee = {
id: number;
bookingId: number;
email: string;
name: string;
timeZone: string;
locale: string | null;
phoneNumber: string | null;
};
type RemoveAttendeeInput = {
bookingId: number;
attendeeId: number;
user: TUser;
emailsEnabled?: boolean;
actionSource: ActionSource;
};
export type BookingAttendeesServiceDeps = {
bookingEventHandlerService: BookingEventHandlerService;
featuresRepository: FeaturesRepository;
bookingRepository: BookingRepository;
bookingAttendeesRemoveService: BookingAttendeesRemoveService;
};
export class BookingAttendeesService {
constructor(private readonly deps: BookingAttendeesServiceDeps) {}
async getBookingAttendees(bookingUid: string) {
const booking = await this.deps.bookingRepository.findByUidIncludeEventTypeAttendeesAndUser({
bookingUid,
});
if (!booking) {
throw new Error(`Booking with uid ${bookingUid} not found`);
}
return booking.attendees;
}
async getBookingAttendee(bookingUid: string, attendeeId: number) {
const booking = await this.deps.bookingRepository.findByUidIncludeEventTypeAttendeesAndUser({
bookingUid,
});
if (!booking) {
throw new Error(`Booking with uid ${bookingUid} not found`);
}
const attendee = booking.attendees.find((a) => a.id === attendeeId);
if (!attendee) {
throw new ErrorWithCode(
ErrorCode.NotFound,
`Attendee with id ${attendeeId} not found in booking ${bookingUid}`
);
}
return attendee;
}
async addAttendee({
bookingId,
attendee,
user,
emailsEnabled = true,
actionSource,
}: AddAttendeeInput): Promise<CreatedAttendee> {
const booking = await getBooking(bookingId);
await validateUserPermissions(booking, user);
validateGuestsFieldEnabled(booking);
const organizer = await getOrganizerData(booking.userId);
const validatedAttendees = await sanitizeAndFilterGuests([attendee], booking);
const newAttendeeDetails = validatedAttendees.map((a) => ({
name: a.name || "",
email: a.email,
timeZone: a.timeZone || organizer.timeZone,
locale: a.language || organizer.locale,
phoneNumber: a.phoneNumber || null,
}));
const attendeeEmail = attendee.email;
const updatedBooking = await updateBookingAttendees(
bookingId,
newAttendeeDetails,
[attendeeEmail],
booking
);
const allAttendees = await prepareAttendeesList(updatedBooking.attendees);
const evt = await buildCalendarEvent(booking, organizer, allAttendees);
await updateCalendarEvent(booking, evt);
if (emailsEnabled) {
await this.sendAttendeeNotification(evt, booking, attendeeEmail);
}
const organizationId = user.organizationId ?? null;
const isBookingAuditEnabled = organizationId
? await this.deps.featuresRepository.checkIfTeamHasFeature(organizationId, "booking-audit")
: false;
await this.deps.bookingEventHandlerService.onAttendeeAdded({
bookingUid: booking.uid,
actor: makeUserActor(user.uuid),
organizationId,
source: actionSource,
auditData: {
added: [attendeeEmail],
},
isBookingAuditEnabled,
});
const createdAttendee = updatedBooking.attendees.find(
(a) => a.email.toLowerCase() === attendeeEmail.toLowerCase()
);
if (!createdAttendee) {
throw new Error("Attendee was created but could not be found");
}
return {
id: createdAttendee.id,
bookingId,
email: createdAttendee.email,
name: createdAttendee.name,
timeZone: createdAttendee.timeZone,
locale: createdAttendee.locale,
phoneNumber: createdAttendee.phoneNumber,
};
}
async removeAttendee(input: RemoveAttendeeInput) {
return this.deps.bookingAttendeesRemoveService.removeAttendee(input);
}
private async sendAttendeeNotification(
evt: CalendarEvent,
booking: Booking,
attendeeEmail: string
): Promise<void> {
const emailsAndSmsHandler = new BookingEmailSmsHandler({
logger: logger,
});
await emailsAndSmsHandler.handleAddAttendee({
evt,
eventType: {
metadata: eventTypeMetaDataSchemaWithTypedApps.parse(booking?.eventType?.metadata),
schedulingType: booking.eventType?.schedulingType || null,
},
newGuests: [attendeeEmail],
});
}
}
export type { RemovedAttendee } from "@calcom/features/bookings/services/BookingAttendeesRemoveService";