fix: Missing bookingId in BOOKING_CANCELLED webhook payload (#22713)

* send bookingId in BOOKING_CANCELLED through requestReschedule

* fix: make customInputs nullable in BookingWebhookFactory

* fix: make customInputs nullable in BookingWebhookFactory
This commit is contained in:
Hariom Balhara
2025-08-01 06:04:53 +00:00
committed by GitHub
parent 6e3de84067
commit aa618dbaa2
4 changed files with 483 additions and 29 deletions
@@ -0,0 +1,127 @@
import type { Person } from "@calcom/types/Calendar";
import type { JsonValue } from "@calcom/types/JsonObject";
function isObjectButNotArray(obj: unknown): obj is Record<string, unknown> {
return typeof obj === "object" && !Array.isArray(obj);
}
type DestinationCalendar = {
id: number;
integration: string;
externalId: string;
primaryEmail: string | null;
userId: number | null;
eventTypeId: number | null;
credentialId: number | null;
createdAt: Date | null;
updatedAt: Date | null;
delegationCredentialId: string | null;
domainWideDelegationCredentialId: string | null;
};
type Response = {
label: string;
value: string | boolean | string[] | { value: string; optionValue: string } | Record<string, string>;
isHidden?: boolean | undefined;
};
interface BaseWebhookPayload {
bookingId: number;
title: string;
eventSlug: string | null;
description: string | null;
customInputs: JsonValue | null;
responses: Record<string, Response>;
userFieldsResponses: Record<string, Response>;
startTime: string;
endTime: string;
organizer: Person;
attendees: Person[];
uid: string;
location: string | null;
destinationCalendar: DestinationCalendar | null;
cancellationReason: string | null;
iCalUID: string | null;
smsReminderNumber?: string;
cancelledBy: string | null;
}
interface CancelledEventPayload extends BaseWebhookPayload {
cancelledBy: string;
cancellationReason: string;
}
export class BookingWebhookFactory {
private getType(params: BaseWebhookPayload) {
return params.eventSlug || params.title || "";
}
private getTitle(params: BaseWebhookPayload) {
return params.title || "";
}
private getDestinationCalendar(params: BaseWebhookPayload) {
return params.destinationCalendar ? [params.destinationCalendar] : [];
}
private getCustomInputs(params: BaseWebhookPayload) {
return isObjectButNotArray(params.customInputs) ? params.customInputs : undefined;
}
/**
* Creates base webhook payload with common fields
*/
private createBasePayload(params: BaseWebhookPayload) {
const {
bookingId,
title,
eventSlug,
description,
customInputs,
startTime,
endTime,
uid,
location,
organizer,
attendees,
responses,
userFieldsResponses,
destinationCalendar,
smsReminderNumber,
iCalUID,
} = params;
const basePayload = {
bookingId,
type: this.getType(params),
title: this.getTitle(params),
description,
customInputs: this.getCustomInputs(params),
responses,
userFieldsResponses,
startTime,
endTime,
organizer,
attendees,
uid,
location,
destinationCalendar: this.getDestinationCalendar(params),
iCalUID,
smsReminderNumber,
};
return basePayload;
}
public createCancelledEventPayload(params: CancelledEventPayload) {
const basePayload = this.createBasePayload({
...params,
});
return {
...basePayload,
cancelledBy: params.cancelledBy,
cancellationReason: params.cancellationReason,
};
}
}
@@ -0,0 +1,318 @@
import { describe, it, expect } from "vitest";
import type { Person } from "@calcom/types/Calendar";
import { BookingWebhookFactory } from "../BookingWebhookFactory";
const createTestOrganizer = (overrides?: Partial<Person>): Person => ({
email: "organizer@example.com",
name: "Test Organizer",
timeZone: "UTC",
language: { locale: "en", translate: (() => "") as any },
...overrides,
});
const createTestAttendee = (overrides?: Partial<Person>): Person => ({
email: "attendee@example.com",
name: "Test Attendee",
timeZone: "UTC",
language: { locale: "en", translate: (() => "") as any },
...overrides,
});
const createTestBooking = (overrides?: Record<string, any>) => ({
id: 12345,
uid: "test-uid-123",
title: "Test Event",
description: "Test Description",
startTime: new Date("2025-01-24T10:00:00Z"),
endTime: new Date("2025-01-24T11:00:00Z"),
location: "https://meet.example.com",
customInputs: { field1: "value1" },
responses: { name: { value: "Test User" } },
userFieldsResponses: { company: { value: "Test Corp" } },
smsReminderNumber: "+1234567890",
iCalUID: "ical-uid-123",
...overrides,
});
describe("BookingWebhookFactory", () => {
describe("createCancelledEventPayload", () => {
it("should create a basic cancelled event payload with required fields", () => {
const factory = new BookingWebhookFactory();
const booking = createTestBooking();
const organizer = createTestOrganizer();
const attendees = [createTestAttendee()];
const payload = factory.createCancelledEventPayload({
bookingId: booking.id,
uid: booking.uid,
title: booking.title,
eventSlug: "test-event",
description: booking.description,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
location: booking.location,
customInputs: booking.customInputs,
responses: booking.responses,
userFieldsResponses: booking.userFieldsResponses,
smsReminderNumber: booking.smsReminderNumber,
iCalUID: booking.iCalUID,
organizer,
attendees,
destinationCalendar: null,
cancelledBy: "test@example.com",
cancellationReason: "User requested",
});
expect(payload.bookingId).toBe(12345);
expect(payload.uid).toBe("test-uid-123");
expect(payload.title).toBe("Test Event");
expect(payload.description).toBe("Test Description");
expect(payload.organizer).toEqual(organizer);
expect(payload.attendees).toEqual(attendees);
expect(payload.type).toBe("test-event");
expect(payload.startTime).toBe(booking.startTime.toISOString());
expect(payload.endTime).toBe(booking.endTime.toISOString());
expect(payload.location).toBe("https://meet.example.com");
expect(payload.customInputs).toEqual({ field1: "value1" });
expect(payload.responses).toEqual({ name: { value: "Test User" } });
expect(payload.userFieldsResponses).toEqual({ company: { value: "Test Corp" } });
expect(payload.smsReminderNumber).toBe("+1234567890");
expect(payload.iCalUID).toBe("ical-uid-123");
expect(payload.destinationCalendar).toEqual([]);
expect(payload.cancellationReason).toBe("User requested");
expect(payload.cancelledBy).toBe("test@example.com");
// Verify no extra properties exist
const expectedKeys = [
"bookingId",
"type",
"title",
"description",
"customInputs",
"responses",
"userFieldsResponses",
"startTime",
"endTime",
"organizer",
"attendees",
"uid",
"location",
"destinationCalendar",
"iCalUID",
"smsReminderNumber",
"cancellationReason",
"cancelledBy",
];
const actualKeys = Object.keys(payload).sort();
expect(actualKeys).toEqual(expectedKeys.sort());
});
it("should handle responses and userFieldsResponses", () => {
const factory = new BookingWebhookFactory();
const booking = createTestBooking();
const responses = { field1: "response1", field2: "response2" };
const userFieldsResponses = { company: "Test Corp", department: "Engineering" };
const payload = factory.createCancelledEventPayload({
bookingId: booking.id,
uid: booking.uid,
title: booking.title,
eventSlug: null,
description: null,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
location: null,
customInputs: null,
responses,
userFieldsResponses,
smsReminderNumber: null,
iCalUID: null,
organizer: createTestOrganizer(),
attendees: [createTestAttendee()],
destinationCalendar: null,
cancelledBy: "user@example.com",
cancellationReason: "Test",
});
expect(payload.responses).toEqual(responses);
expect(payload.userFieldsResponses).toEqual(userFieldsResponses);
});
it("should handle destination calendar as single object", () => {
const factory = new BookingWebhookFactory();
const booking = createTestBooking();
const destinationCalendar = {
id: 1,
integration: "google",
externalId: "cal123",
primaryEmail: null,
userId: null,
eventTypeId: null,
credentialId: null,
createdAt: null,
updatedAt: null,
delegationCredentialId: null,
domainWideDelegationCredentialId: null,
};
const payload = factory.createCancelledEventPayload({
bookingId: booking.id,
uid: booking.uid,
title: booking.title,
eventSlug: null,
description: null,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
location: null,
customInputs: null,
responses: {},
userFieldsResponses: {},
smsReminderNumber: null,
iCalUID: null,
organizer: createTestOrganizer(),
attendees: [],
destinationCalendar,
cancelledBy: "user@example.com",
cancellationReason: "Test",
});
expect(payload.destinationCalendar).toEqual([destinationCalendar]);
});
it("should handle null destination calendar", () => {
const factory = new BookingWebhookFactory();
const booking = createTestBooking();
const payload = factory.createCancelledEventPayload({
bookingId: booking.id,
uid: booking.uid,
title: booking.title,
eventSlug: null,
description: null,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
location: null,
customInputs: null,
responses: {},
userFieldsResponses: {},
smsReminderNumber: null,
iCalUID: null,
organizer: createTestOrganizer(),
attendees: [],
destinationCalendar: null,
cancelledBy: "user@example.com",
cancellationReason: "Test",
});
expect(payload.destinationCalendar).toEqual([]);
});
it("should handle sms reminder number and iCal UID", () => {
const factory = new BookingWebhookFactory();
const booking = createTestBooking();
const payload = factory.createCancelledEventPayload({
bookingId: booking.id,
uid: booking.uid,
title: booking.title,
eventSlug: null,
description: null,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
location: null,
customInputs: null,
responses: {},
userFieldsResponses: {},
smsReminderNumber: "+1234567890",
iCalUID: "ical-uid-123",
organizer: createTestOrganizer(),
attendees: [],
destinationCalendar: null,
cancelledBy: "user@example.com",
cancellationReason: "Test",
});
expect(payload.smsReminderNumber).toBe("+1234567890");
expect(payload.iCalUID).toBe("ical-uid-123");
});
it("should derive type from eventSlug when available, otherwise from title", () => {
const factory = new BookingWebhookFactory();
const booking = createTestBooking();
// Test with eventSlug provided
const payloadWithSlug = factory.createCancelledEventPayload({
bookingId: booking.id,
uid: booking.uid,
title: "Meeting Title",
eventSlug: "team-standup",
description: null,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
location: null,
customInputs: null,
responses: {},
userFieldsResponses: {},
smsReminderNumber: null,
iCalUID: null,
organizer: createTestOrganizer(),
attendees: [],
destinationCalendar: null,
cancelledBy: "user@example.com",
cancellationReason: "Test",
});
expect(payloadWithSlug.type).toBe("team-standup");
// Test with eventSlug null, should use title
const payloadWithoutSlug = factory.createCancelledEventPayload({
bookingId: booking.id,
uid: booking.uid,
title: "Meeting Title",
eventSlug: null,
description: null,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
location: null,
customInputs: null,
responses: {},
userFieldsResponses: {},
smsReminderNumber: null,
iCalUID: null,
organizer: createTestOrganizer(),
attendees: [],
destinationCalendar: null,
cancelledBy: "user@example.com",
cancellationReason: "Test",
});
expect(payloadWithoutSlug.type).toBe("Meeting Title");
// Test with both eventSlug and title empty
const payloadEmpty = factory.createCancelledEventPayload({
bookingId: booking.id,
uid: booking.uid,
title: "",
eventSlug: "",
description: null,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
location: null,
customInputs: null,
responses: {},
userFieldsResponses: {},
smsReminderNumber: null,
iCalUID: null,
organizer: createTestOrganizer(),
attendees: [],
destinationCalendar: null,
cancelledBy: "user@example.com",
cancellationReason: "Test",
});
expect(payloadEmpty.type).toBe("");
});
});
});
@@ -14,18 +14,18 @@ import { getDelegationCredentialOrRegularCredential } from "@calcom/lib/delegati
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/lib/server/getUsersCredentials";
import { getTranslation } from "@calcom/lib/server/i18n";
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
import { BookingWebhookFactory } from "@calcom/lib/server/service/BookingWebhookFactory";
import { deleteMeeting } from "@calcom/lib/videoClient";
import { prisma } from "@calcom/prisma";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import type { Person } from "@calcom/types/Calendar";
import { TRPCError } from "@trpc/server";
@@ -109,7 +109,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
if (bookingBelongsToTeam && bookingToReschedule.eventType?.teamId) {
const userTeamIds = userTeams.teams.map((item) => item.teamId);
if (userTeamIds.indexOf(bookingToReschedule?.eventType?.teamId) === -1) {
if (userTeamIds.indexOf(bookingToReschedule.eventType?.teamId) === -1) {
throw new TRPCError({ code: "FORBIDDEN", message: "User isn't a member on the team" });
}
log.debug(
@@ -182,7 +182,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
const [userAsPeopleType] = usersToPeopleType([user], userTranslation);
const organizer = {
...userAsPeopleType,
email: bookingToReschedule?.userPrimaryEmail ?? userAsPeopleType.email,
email: bookingToReschedule.userPrimaryEmail ?? userAsPeopleType.email,
};
const builder = new CalendarEventBuilder();
@@ -276,31 +276,38 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
eventType?.metadata as EventTypeMetadata
);
const evt: CalendarEvent = {
title: bookingToReschedule?.title,
type: event && event.slug ? event.slug : bookingToReschedule.title,
description: bookingToReschedule?.description || "",
customInputs: isPrismaObjOrUndefined(bookingToReschedule.customInputs),
...getCalEventResponses({
booking: bookingToReschedule,
bookingFields: bookingToReschedule.eventType?.bookingFields ?? null,
}),
startTime: bookingToReschedule?.startTime ? dayjs(bookingToReschedule.startTime).format() : "",
endTime: bookingToReschedule?.endTime ? dayjs(bookingToReschedule.endTime).format() : "",
const calEventResponses = getCalEventResponses({
booking: bookingToReschedule,
bookingFields: bookingToReschedule.eventType?.bookingFields ?? null,
});
const webhookFactory = new BookingWebhookFactory();
const payload = webhookFactory.createCancelledEventPayload({
bookingId: bookingToReschedule.id,
title: bookingToReschedule.title,
eventSlug: event.slug ?? null,
description: bookingToReschedule.description,
customInputs: bookingToReschedule.customInputs,
responses: calEventResponses.responses,
userFieldsResponses: calEventResponses.userFieldsResponses,
startTime: bookingToReschedule.startTime ? dayjs(bookingToReschedule.startTime).format() : "",
endTime: bookingToReschedule.endTime ? dayjs(bookingToReschedule.endTime).format() : "",
organizer,
attendees: usersToPeopleType(
// username field doesn't exists on attendee but could be in the future
bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[],
tAttendees
),
uid: bookingToReschedule?.uid,
location: bookingToReschedule?.location,
destinationCalendar: bookingToReschedule?.destinationCalendar
? [bookingToReschedule?.destinationCalendar]
: [],
uid: bookingToReschedule.uid,
location: bookingToReschedule.location,
destinationCalendar: bookingToReschedule.destinationCalendar,
cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this
iCalUID: bookingToReschedule?.iCalUID,
};
iCalUID: bookingToReschedule.iCalUID,
...(bookingToReschedule.smsReminderNumber && {
smsReminderNumber: bookingToReschedule.smsReminderNumber,
}),
cancelledBy: user.email,
});
// Send webhook
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
@@ -308,7 +315,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: bookingToReschedule.eventType?.teamId ?? null },
parentId: bookingToReschedule?.eventType?.parentId ?? null,
parentId: bookingToReschedule.eventType?.parentId ?? null,
},
});
@@ -327,13 +334,9 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
const webhooks = await getWebhooks(subscriberOptions);
const promises = webhooks.map((webhook) =>
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
...evt,
smsReminderNumber: bookingToReschedule.smsReminderNumber || undefined,
cancelledBy: user.email,
}).catch((e) => {
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, payload).catch((e) => {
log.error(
`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${payload.bookingId}, bookingUid: ${payload.uid}`,
safeStringify(e)
);
})
+6
View File
@@ -0,0 +1,6 @@
/**
* Decouples the types from Prisma
*/
export type JsonValue = string | number | boolean | null | JsonObject | JsonArray;
export type JsonArray = JsonValue[];
export type JsonObject = { [Key in string]?: JsonValue };