diff --git a/packages/lib/server/service/BookingWebhookFactory.ts b/packages/lib/server/service/BookingWebhookFactory.ts new file mode 100644 index 0000000000..44141099a7 --- /dev/null +++ b/packages/lib/server/service/BookingWebhookFactory.ts @@ -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 { + 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; + isHidden?: boolean | undefined; +}; + +interface BaseWebhookPayload { + bookingId: number; + title: string; + eventSlug: string | null; + description: string | null; + customInputs: JsonValue | null; + responses: Record; + userFieldsResponses: Record; + 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, + }; + } +} diff --git a/packages/lib/server/service/__tests__/BookingWebhookFactory.test.ts b/packages/lib/server/service/__tests__/BookingWebhookFactory.test.ts new file mode 100644 index 0000000000..11da828849 --- /dev/null +++ b/packages/lib/server/service/__tests__/BookingWebhookFactory.test.ts @@ -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 => ({ + email: "organizer@example.com", + name: "Test Organizer", + timeZone: "UTC", + language: { locale: "en", translate: (() => "") as any }, + ...overrides, +}); + +const createTestAttendee = (overrides?: Partial): Person => ({ + email: "attendee@example.com", + name: "Test Attendee", + timeZone: "UTC", + language: { locale: "en", translate: (() => "") as any }, + ...overrides, +}); + +const createTestBooking = (overrides?: Record) => ({ + 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(""); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index 32d8216ea3..9911b5e6f4 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -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) ); }) diff --git a/packages/types/JsonObject.d.ts b/packages/types/JsonObject.d.ts new file mode 100644 index 0000000000..2dfba36d47 --- /dev/null +++ b/packages/types/JsonObject.d.ts @@ -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 };