diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 8520d9aac56c98..e10c43eff99b58 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3677,6 +3677,8 @@ "webhook_organizer_email": "Email of the organizer", "webhook_organizer_timezone": "Timezone of the organizer (e.g., 'America/New_York', 'Asia/Kolkata')", "webhook_organizer_locale": "Locale of the organizer (e.g., 'en', 'fr')", + "webhook_organizer_username": "Username of the organizer (e.g., 'john.doe')", + "webhook_organizer_username_in_org": "Username of the organizer in their organization (e.g., 'john.doe')", "webhook_attendee_name": "Name of the first attendee", "webhook_attendee_email": "Email of the first attendee", "webhook_attendee_timezone": "Timezone of the first attendee", diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 7577bddfd8a1cc..06e33ce6f80593 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -29,18 +29,22 @@ import type { BookingStatus } from "@calcom/prisma/enums"; import type { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import type { userMetadataType } from "@calcom/prisma/zod-utils"; import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; +import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { AppMeta } from "@calcom/types/App"; import type { + Calendar, CalendarEvent, IntegrationCalendar, NewCalendarEventType, EventBusyDate, } from "@calcom/types/Calendar"; -import type { CredentialForCalendarService } from "@calcom/types/Credential"; +import type { CredentialPayload } from "@calcom/types/Credential"; +import type { VideoApiAdapter } from "@calcom/types/VideoApiAdapter"; import { getMockPaymentService } from "./MockPaymentService"; import type { getMockRequestDataForBooking } from "./getMockRequestDataForBooking"; +type NonNullableVideoApiAdapter = NonNullable; vi.mock("@calcom/app-store/calendar.services.generated", () => ({ CalendarServiceMap: { googlecalendar: Promise.resolve({ default: vi.fn() }), @@ -50,7 +54,7 @@ vi.mock("@calcom/app-store/calendar.services.generated", () => ({ }, })); -const mockVideoAdapterRegistry: Record = {}; +const mockVideoAdapterRegistry: Record = {}; vi.mock("@calcom/app-store/video.adapters.generated", () => ({ VideoApiAdapterMap: new Proxy( @@ -72,9 +76,7 @@ vi.mock("@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic", () => ({ })); vi.mock("@calcom/lib/crypto", async (importOriginal) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, symmetricEncrypt: vi.fn((serviceAccountKey) => serviceAccountKey), @@ -116,6 +118,17 @@ type InputWorkflow = { verifiedAt?: Date; }; +type PaymentData = { + // Common payment data fields based on Stripe and other payment providers + paymentIntent?: string; + paymentMethodId?: string; + clientSecret?: string; + customerId?: string; + subscriptionId?: string; + metadata?: Record; + [key: string]: unknown; // Allow additional provider-specific fields +}; + type InputPayment = { id?: number; uid: string; @@ -126,7 +139,7 @@ type InputPayment = { currency: string; success: boolean; refunded: boolean; - data: Record; + data: PaymentData; externalId: string; paymentOption?: PaymentOption; }; @@ -258,7 +271,7 @@ export type InputEventType = { bookingLimits?: IntervalLimit; durationLimits?: IntervalLimit; owner?: number; - metadata?: any; + metadata?: z.infer; rescheduleWithSameRoundRobinHost?: boolean; restrictionSchedule?: { create: { @@ -296,6 +309,7 @@ type WhiteListedBookingProps = { email: string; phoneNumber?: string; bookingSeat?: AttendeeBookingSeatInput | null; + timeZone?: string; }[]; references?: (Omit, "credentialId"> & { // TODO: Make sure that all references start providing credentialId and then remove this intersection of optional credentialId @@ -370,13 +384,18 @@ export async function addEventTypesToDb( "users" | "workflows" | "destinationCalendar" | "schedule" > & { id?: number; - users?: any[]; + users?: ({ id: number } | undefined)[]; userId?: number; - hosts?: any[]; - workflows?: any[]; - destinationCalendar?: any; - schedule?: any; - metadata?: any; + hosts?: { + user: InputUser | undefined; + id: number; + }[]; + workflows?: Prisma.WorkflowCreateInput[]; + destinationCalendar?: { + create: Prisma.DestinationCalendarCreateInput; + }; + schedule?: { create: Prisma.ScheduleCreateInput } | null | undefined; + metadata?: z.infer; team?: { id?: number | null; bookingLimits?: IntervalLimit; includeManagedEventsInLimits?: boolean }; restrictionSchedule?: { create: { @@ -559,7 +578,8 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In }; }); log.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers)); - return await addEventTypesToDb(eventTypesWithUsers); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Pretty complex type here + return await addEventTypesToDb(eventTypesWithUsers as unknown as any); } function addBookingReferencesToDB(bookingReferences: Prisma.BookingReferenceCreateManyInput[]) { @@ -941,7 +961,6 @@ export async function addUsers(users: InputUser[]) { } if (user.profiles) { newUser.profiles = { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error Not sure why this is not working createMany: { data: user.profiles, @@ -1062,7 +1081,7 @@ export async function createOrganization(orgData: { export async function createCredentials( credentialData: { type: string; - key: any; + key: Prisma.JsonValue; id?: number; userId?: number | null; teamId?: number | null; @@ -1575,7 +1594,7 @@ export function getOrganizer({ completedOnboarding, locked, emailVerified, - }; + }; } export function getScenarioData( @@ -1603,7 +1622,7 @@ export function getScenarioData( bookings?: ScenarioData["bookings"]; payment?: ScenarioData["payment"]; }, - org?: { id: number | null } | undefined | null + org?: { id: number | null; profileUsername?: string } | undefined | null ) { if (_users && (usersApartFromOrganizer.length || organizer)) { throw new Error("When users are provided, usersApartFromOrganizer and organizer should not be provided"); @@ -1617,11 +1636,13 @@ export function getScenarioData( if (!orgId) { throw new Error("If org is specified org.id is required"); } + users.forEach((user) => { + const profileUsername = org.profileUsername ?? user.username ?? ""; user.profiles = [ { organizationId: orgId, - username: user.username || "", + username: profileUsername, uid: ProfileRepository.generateProfileUid(), }, ]; @@ -1694,7 +1715,7 @@ export const enum BookingLocations { export type CalendarServiceMethodMockCallBase = { calendarServiceConstructorArgs: { - credential: CredentialForCalendarService; + credential: CredentialPayload; }; }; @@ -1710,7 +1731,7 @@ type UpdateEventMethodMockCall = CalendarServiceMethodMockCallBase & { args: { uid: string; event: CalendarEvent; - externalCalendarId?: string; + externalCalendarId?: string | null; }; }; @@ -1718,7 +1739,7 @@ type DeleteEventMethodMockCall = CalendarServiceMethodMockCallBase & { args: { uid: string; event: CalendarEvent; - externalCalendarId?: string; + externalCalendarId?: string | null; }; }; @@ -1800,156 +1821,161 @@ export async function mockCalendar( const calendarServicePromise = CalendarServiceMap[calendarServiceKey]; if (calendarServicePromise) { const resolvedService = await calendarServicePromise; - vi.mocked(resolvedService.default as any).mockImplementation(function MockCalendarService( - credential: any - ) { - return { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - createEvent: async function (...rest: any[]): Promise { - if (calendarData?.creationCrash) { - throw new Error("MockCalendarService.createEvent fake error"); - } - const [calEvent, credentialId, externalCalendarId] = rest; - log.debug( - "mockCalendar.createEvent", - JSON.stringify({ calEvent, credentialId, externalCalendarId }) - ); - createEventCalls.push({ - args: { - calEvent, - credentialId, - externalCalendarId, - }, - calendarServiceConstructorArgs: { - credential, - }, - }); - const isGoogleMeetLocation = calEvent?.location === BookingLocations.GoogleMeet; - if (app.type === "google_calendar") { - return Promise.resolve({ - type: app.type, - additionalInfo: { + vi.mocked(resolvedService.default).mockImplementation( + // @ts-expect-error - Mock implementation satisfies Calendar interface but TypeScript expects specific calendar service types + function MockCalendarService(credential) { + return { + createEvent: async function ( + ...rest: Parameters + ): Promise { + if (calendarData?.creationCrash) { + throw new Error("MockCalendarService.createEvent fake error"); + } + const [calEvent, credentialId, externalCalendarId] = rest; + log.debug( + "mockCalendar.createEvent", + JSON.stringify({ calEvent, credentialId, externalCalendarId }) + ); + createEventCalls.push({ + args: { + calEvent, + credentialId, + externalCalendarId, + }, + calendarServiceConstructorArgs: { + credential, + }, + }); + const isGoogleMeetLocation = calEvent?.location === BookingLocations.GoogleMeet; + if (app.type === "google_calendar") { + return Promise.resolve({ + type: app.type, + additionalInfo: { + hangoutLink: + normalizedCalendarData.create?.appSpecificData?.googleCalendar?.hangoutLink || + "https://GOOGLE_MEET_URL_IN_CALENDAR_EVENT", + }, hangoutLink: normalizedCalendarData.create?.appSpecificData?.googleCalendar?.hangoutLink || "https://GOOGLE_MEET_URL_IN_CALENDAR_EVENT", + uid: normalizedCalendarData.create?.uid || "GOOGLE_CALENDAR_EVENT_ID", + id: normalizedCalendarData.create?.id || "GOOGLE_CALENDAR_EVENT_ID", + iCalUID: + normalizedCalendarData.create?.iCalUID || calEvent.iCalUID || "GOOGLE_CALENDAR_EVENT_ID", + password: "MOCK_PASSWORD", + url: + normalizedCalendarData.create?.appSpecificData?.googleCalendar?.hangoutLink || + "https://GOOGLE_MEET_URL_IN_CALENDAR_EVENT", + }); + } else if (app.type === "office365_calendar") { + return Promise.resolve({ + type: app.type, + additionalInfo: {}, + uid: normalizedCalendarData.create?.uid || "OFFICE_365_CALENDAR_EVENT_ID", + id: normalizedCalendarData.create?.id || "OFFICE_365_CALENDAR_EVENT_ID", + iCalUID: + normalizedCalendarData.create?.iCalUID || + calEvent.iCalUID || + "OFFICE_365_CALENDAR_EVENT_ID", + password: "MOCK_PASSWORD", + url: + normalizedCalendarData.create?.appSpecificData?.office365Calendar?.url || + "https://UNUSED_URL", + }); + } else { + return Promise.resolve({ + type: app.type, + additionalInfo: {}, + uid: "PROBABLY_UNUSED_UID", + hangoutLink: + (isGoogleMeetLocation + ? normalizedCalendarData.create?.appSpecificData?.googleCalendar?.hangoutLink + : null) || "https://UNUSED_URL", + // A Calendar is always expected to return an id. + id: normalizedCalendarData.create?.id || "FALLBACK_MOCK_CALENDAR_EVENT_ID", + iCalUID: normalizedCalendarData.create?.iCalUID, + // Password and URL seems useless for CalendarService, plan to remove them if that's the case + password: "MOCK_PASSWORD", + url: "https://UNUSED_URL", + }); + } + }, + updateEvent: async function ( + ...rest: Parameters + ): Promise { + if (calendarData?.updationCrash) { + throw new Error("MockCalendarService.updateEvent fake error"); + } + const [uid, event, externalCalendarId] = rest; + log.silly("mockCalendar.updateEvent", JSON.stringify({ uid, event, externalCalendarId })); + updateEventCalls.push({ + args: { + uid, + event, + externalCalendarId, + }, + calendarServiceConstructorArgs: { + credential, }, - hangoutLink: - normalizedCalendarData.create?.appSpecificData?.googleCalendar?.hangoutLink || - "https://GOOGLE_MEET_URL_IN_CALENDAR_EVENT", - uid: normalizedCalendarData.create?.uid || "GOOGLE_CALENDAR_EVENT_ID", - id: normalizedCalendarData.create?.id || "GOOGLE_CALENDAR_EVENT_ID", - iCalUID: - normalizedCalendarData.create?.iCalUID || calEvent.iCalUID || "GOOGLE_CALENDAR_EVENT_ID", - password: "MOCK_PASSWORD", - url: - normalizedCalendarData.create?.appSpecificData?.googleCalendar?.hangoutLink || - "https://GOOGLE_MEET_URL_IN_CALENDAR_EVENT", - }); - } else if (app.type === "office365_calendar") { - return Promise.resolve({ - type: app.type, - additionalInfo: {}, - uid: normalizedCalendarData.create?.uid || "OFFICE_365_CALENDAR_EVENT_ID", - id: normalizedCalendarData.create?.id || "OFFICE_365_CALENDAR_EVENT_ID", - iCalUID: - normalizedCalendarData.create?.iCalUID || calEvent.iCalUID || "OFFICE_365_CALENDAR_EVENT_ID", - password: "MOCK_PASSWORD", - url: - normalizedCalendarData.create?.appSpecificData?.office365Calendar?.url || - "https://UNUSED_URL", }); - } else { + const isGoogleMeetLocation = event.location === BookingLocations.GoogleMeet; return Promise.resolve({ type: app.type, additionalInfo: {}, uid: "PROBABLY_UNUSED_UID", - hangoutLink: - (isGoogleMeetLocation - ? normalizedCalendarData.create?.appSpecificData?.googleCalendar?.hangoutLink - : null) || "https://UNUSED_URL", - // A Calendar is always expected to return an id. - id: normalizedCalendarData.create?.id || "FALLBACK_MOCK_CALENDAR_EVENT_ID", - iCalUID: normalizedCalendarData.create?.iCalUID, + iCalUID: normalizedCalendarData.update?.iCalUID, + id: normalizedCalendarData.update?.uid || "FALLBACK_MOCK_ID", // Password and URL seems useless for CalendarService, plan to remove them if that's the case password: "MOCK_PASSWORD", url: "https://UNUSED_URL", + location: isGoogleMeetLocation ? "https://UNUSED_URL" : undefined, + hangoutLink: + (isGoogleMeetLocation + ? normalizedCalendarData.update?.appSpecificData?.googleCalendar?.hangoutLink + : null) || "https://UNUSED_URL", + conferenceData: isGoogleMeetLocation ? event.conferenceData : undefined, }); - } - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - updateEvent: async function (...rest: any[]): Promise { - if (calendarData?.updationCrash) { - throw new Error("MockCalendarService.updateEvent fake error"); - } - const [uid, event, externalCalendarId] = rest; - log.silly("mockCalendar.updateEvent", JSON.stringify({ uid, event, externalCalendarId })); - // eslint-disable-next-line prefer-rest-params - updateEventCalls.push({ - args: { - uid, - event, - externalCalendarId, - }, - calendarServiceConstructorArgs: { - credential, - }, - }); - const isGoogleMeetLocation = event.location === BookingLocations.GoogleMeet; - return Promise.resolve({ - type: app.type, - additionalInfo: {}, - uid: "PROBABLY_UNUSED_UID", - iCalUID: normalizedCalendarData.update?.iCalUID, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - id: normalizedCalendarData.update?.uid || "FALLBACK_MOCK_ID", - // Password and URL seems useless for CalendarService, plan to remove them if that's the case - password: "MOCK_PASSWORD", - url: "https://UNUSED_URL", - location: isGoogleMeetLocation ? "https://UNUSED_URL" : undefined, - hangoutLink: - (isGoogleMeetLocation - ? normalizedCalendarData.update?.appSpecificData?.googleCalendar?.hangoutLink - : null) || "https://UNUSED_URL", - conferenceData: isGoogleMeetLocation ? event.conferenceData : undefined, - }); - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - deleteEvent: async (...rest: any[]) => { - log.silly("mockCalendar.deleteEvent", JSON.stringify({ rest })); - // eslint-disable-next-line prefer-rest-params - deleteEventCalls.push({ - args: { - uid: rest[0], - event: rest[1], - externalCalendarId: rest[2], - }, - calendarServiceConstructorArgs: { - credential, - }, - }); - }, - getAvailability: async (...rest: any[]): Promise => { - if (calendarData?.getAvailabilityCrash) { - throw new Error("MockCalendarService.getAvailability fake error"); - } - const [dateFrom, dateTo, selectedCalendars, shouldServeCache] = rest; - getAvailabilityCalls.push({ - args: { - dateFrom, - dateTo, - selectedCalendars, - shouldServeCache, - }, - calendarServiceConstructorArgs: { - credential, - }, - }); - return new Promise((resolve) => { - resolve(calendarData?.busySlots || []); - }); - }, - }; - }); + }, + deleteEvent: async (...rest: Parameters) => { + log.silly("mockCalendar.deleteEvent", JSON.stringify({ rest })); + deleteEventCalls.push({ + args: { + uid: rest[0], + event: rest[1], + externalCalendarId: rest[2], + }, + calendarServiceConstructorArgs: { + credential, + }, + }); + }, + getAvailability: async ( + dateFrom: string, + dateTo: string, + selectedCalendars: IntegrationCalendar[], + shouldServeCache?: boolean + ): Promise => { + if (calendarData?.getAvailabilityCrash) { + throw new Error("MockCalendarService.getAvailability fake error"); + } + getAvailabilityCalls.push({ + args: { + dateFrom, + dateTo, + selectedCalendars, + shouldServeCache, + }, + calendarServiceConstructorArgs: { + credential, + }, + }); + return new Promise((resolve) => { + resolve(calendarData?.busySlots || []); + }); + }, + } as Calendar; + } + ); } return { @@ -2014,10 +2040,9 @@ export function mockVideoApp({ // eslint-disable-next-line @typescript-eslint/no-explicit-any const deleteMeetingCalls: any[] = []; - const mockVideoAdapter = (credential: any) => { + const mockVideoAdapter = (credential: unknown) => { return { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - createMeeting: (...rest: any[]) => { + createMeeting: (...rest: Parameters) => { if (creationCrash) { throw new Error("MockVideoApiAdapter.createMeeting fake error"); } @@ -2031,8 +2056,7 @@ export function mockVideoApp({ ...videoMeetingData, }); }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - updateMeeting: async (...rest: any[]) => { + updateMeeting: async (...rest: Parameters) => { if (updationCrash) { throw new Error("MockVideoApiAdapter.updateMeeting fake error"); } @@ -2053,8 +2077,7 @@ export function mockVideoApp({ ...videoMeetingData, }); }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - deleteMeeting: async (...rest: any[]) => { + deleteMeeting: async (...rest: Parameters) => { log.silly("MockVideoApiAdapter.deleteMeeting", JSON.stringify(rest)); deleteMeetingCalls.push({ credential, @@ -2116,7 +2139,7 @@ export function mockPaymentApp({ metadataLookupKey: string; appStoreLookupKey?: string; }) { - appStoreLookupKey = appStoreLookupKey || metadataLookupKey; + const _appStoreLookupKey = appStoreLookupKey || metadataLookupKey; const { paymentUid, externalId } = getMockPaymentService(); return { @@ -2366,7 +2389,7 @@ export const getDefaultBookingFields = ({ required: true, defaultLabel: "your_name", }, - !!emailField + emailField ? emailField : { name: "email", diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 782cd011c0731b..a381cde434195c 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -306,13 +306,13 @@ export function expectWebhookToHaveBeenCalledWith( if (parsedBody.payload) { if (data.payload) { - if (!!data.payload.metadata) { + if (data.payload.metadata) { expect(parsedBody.payload.metadata).toEqual(expect.objectContaining(data.payload.metadata)); } - if (!!data.payload.responses) + if (data.payload.responses) expect(parsedBody.payload.responses).toEqual(expect.objectContaining(data.payload.responses)); - if (!!data.payload.organizer) + if (data.payload.organizer) expect(parsedBody.payload.organizer).toEqual(expect.objectContaining(data.payload.organizer)); const { responses: _1, metadata: _2, organizer: _3, ...remainingPayload } = data.payload; @@ -1031,6 +1031,7 @@ export function expectBookingRequestedWebhookToHaveBeenFired({ } export function expectBookingCreatedWebhookToHaveBeenFired({ + organizer, booker, location, subscriberUrl, @@ -1039,7 +1040,7 @@ export function expectBookingCreatedWebhookToHaveBeenFired({ isEmailHidden = false, isAttendeePhoneNumberHidden = false, }: { - organizer: { email: string; name: string }; + organizer: { email: string; name: string; username?: string; usernameInOrg?: string }; booker: { email: string; name: string; attendeePhoneNumber?: string }; subscriberUrl: string; location: string; @@ -1048,6 +1049,11 @@ export function expectBookingCreatedWebhookToHaveBeenFired({ isEmailHidden?: boolean; isAttendeePhoneNumberHidden?: boolean; }) { + const organizerPayload = { + username: organizer.username, + ...(organizer.usernameInOrg ? { usernameInOrg: organizer.usernameInOrg } : null), + }; + if (!paidEvent) { expectWebhookToHaveBeenCalledWith(subscriberUrl, { triggerEvent: "BOOKING_CREATED", @@ -1073,6 +1079,7 @@ export function expectBookingCreatedWebhookToHaveBeenFired({ isHidden: false, }, }, + organizer: organizerPayload, }, }); } else { @@ -1103,6 +1110,7 @@ export function expectBookingCreatedWebhookToHaveBeenFired({ value: { optionValue: "", value: location }, }, }, + organizer: organizerPayload, }, }); } @@ -1144,21 +1152,28 @@ export function expectBookingRescheduledWebhookToHaveBeenFired({ } export function expectBookingCancelledWebhookToHaveBeenFired({ + organizer, booker, location, subscriberUrl, payload, }: { - organizer: { email: string; name: string }; + organizer: { email: string; name: string; username?: string; usernameInOrg?: string }; booker: { email: string; name: string }; subscriberUrl: string; location: string; payload?: Record; }) { + const organizerPayload = { + username: organizer.username, + ...(organizer.usernameInOrg ? { usernameInOrg: organizer.usernameInOrg } : null), + }; + expectWebhookToHaveBeenCalledWith(subscriberUrl, { triggerEvent: "BOOKING_CANCELLED", payload: { ...payload, + organizer: organizerPayload, metadata: null, responses: { name: { @@ -1387,14 +1402,12 @@ export function expectSuccessfulVideoMeetingDeletionInCalendar( export async function expectBookingInDBToBeRescheduledFromTo({ from, to }: { from: any; to: any }) { // Expect previous booking to be cancelled await expectBookingToBeInDatabase({ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ...from, status: BookingStatus.CANCELLED, }); // Expect new booking to be created but status would depend on whether the new booking requires confirmation or not. await expectBookingToBeInDatabase({ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ...to, }); } diff --git a/packages/features/CalendarEventBuilder.ts b/packages/features/CalendarEventBuilder.ts index ddaf0cb30feb74..2723cd1d4dc9eb 100644 --- a/packages/features/CalendarEventBuilder.ts +++ b/packages/features/CalendarEventBuilder.ts @@ -81,6 +81,7 @@ export class CalendarEventBuilder { name: string | null; email: string; username?: string; + usernameInOrg?: string; timeZone: string; timeFormat?: TimeFormat; language: { @@ -95,6 +96,7 @@ export class CalendarEventBuilder { name: organizer.name || "Nameless", email: organizer.email, username: organizer.username, + usernameInOrg: organizer.usernameInOrg, timeZone: organizer.timeZone, language: organizer.language, timeFormat: organizer.timeFormat, diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 24e86413baec21..12f6b60c85eaab 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -257,6 +257,7 @@ async function handler(input: CancelBookingInput) { organizer: { id: organizer.id, username: organizer.username || undefined, + usernameInOrg: ownerProfile?.username || undefined, email: bookingToDelete?.userPrimaryEmail ?? organizer.email, name: organizer.name ?? "Nameless", timeZone: organizer.timeZone, diff --git a/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts b/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts index a99278e8defd10..dabcdc8b5df049 100644 --- a/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts +++ b/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts @@ -384,13 +384,6 @@ describe("Cancel Booking", () => { ], }, ], - teams: [ - { - id: 1, - name: "Test Team", - slug: "test-team", - }, - ], users: [organizer, hostAttendee], apps: [TestData.apps["daily-video"]], }) @@ -786,13 +779,6 @@ describe("Cancel Booking", () => { paymentOption: "HOLD", }, ], - teams: [ - { - id: 1, - name: "Test Team", - slug: "test-team", - }, - ], users: [organizer, teamMember], apps: [TestData.apps["daily-video"]], }) @@ -831,7 +817,6 @@ describe("Cancel Booking", () => { const booker = getBooker({ email: "booker@example.com", name: "Booker", - id: 999, }); const organizer = getOrganizer({ @@ -942,4 +927,130 @@ describe("Cancel Booking", () => { expect(result.success).toBe(true); }); + + test("Should trigger BOOKING_CANCELLED webhook with username and usernameInOrg for organization bookings", async () => { + const handleCancelBooking = (await import("@calcom/features/bookings/lib/handleCancelBooking")).default; + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + username: "organizer-username", + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const uidOfBookingToBeCancelled = "org-booking-uid"; + const idOfBookingToBeCancelled = 5080; + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + + await createBookingScenario( + getScenarioData( + { + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CANCELLED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + bookings: [ + { + id: idOfBookingToBeCancelled, + uid: uidOfBookingToBeCancelled, + attendees: [ + { + email: booker.email, + }, + ], + eventTypeId: 1, + userId: 101, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + }, + ], + organizer, + apps: [TestData.apps["daily-video"]], + }, + { + id: 1, + profileUsername: "username-in-org", + } + ) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-org`, + }, + }); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_ORG", + }, + }); + + await handleCancelBooking({ + bookingData: { + id: idOfBookingToBeCancelled, + uid: uidOfBookingToBeCancelled, + cancelledBy: organizer.email, + cancellationReason: "Organization booking cancellation test", + }, + }); + + expectBookingCancelledWebhookToHaveBeenFired({ + booker, + organizer: { + ...organizer, + usernameInOrg: "username-in-org", + }, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + payload: { + cancelledBy: organizer.email, + organizer: { + id: organizer.id, + username: organizer.username, + email: organizer.email, + name: organizer.name, + timeZone: organizer.timeZone, + }, + }, + }); + }); }); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 5c23a246190b7d..d473c146dd7b1f 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1208,7 +1208,6 @@ async function handler( const organizerOrganizationProfile = await prisma.profile.findFirst({ where: { userId: organizerUser.id, - username: dynamicUserList[0], }, }); @@ -1268,6 +1267,7 @@ async function handler( name: organizerUser.name || "Nameless", email: organizerEmail, username: organizerUser.username || undefined, + usernameInOrg: organizerOrganizationProfile?.username || undefined, timeZone: organizerUser.timeZone, language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" }, timeFormat: getTimeFormatStringFromUserTimeFormat(organizerUser.timeFormat), diff --git a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts index d52394affb84fe..26a9e577ca2794 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts @@ -149,10 +149,20 @@ describe("handleNewBooking", () => { organizer, apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], }, - org?.organization + org?.organization ? { + ...org.organization, + profileUsername: "username-in-org" + } : null ) ); + const orgProfiles = await prismaMock.profile.findMany({ + where: { + organizationId: org?.organization.id, + }, + }); + const orgProfile = orgProfiles[0]; + mockSuccessfulVideoMeetingCreation({ metadataLookupKey: "dailyvideo", videoMeetingData: { @@ -170,7 +180,7 @@ describe("handleNewBooking", () => { const mockBookingData = getMockRequestDataForBooking({ data: { - user: organizer.username, + user: orgProfile ? orgProfile.username : organizer.username, eventTypeId: 1, responses: { email: booker.email, @@ -199,7 +209,6 @@ describe("handleNewBooking", () => { await expectBookingToBeInDatabase({ description: "", - uid: createdBooking.uid!, eventTypeId: mockBookingData.eventTypeId, status: BookingStatus.ACCEPTED, @@ -246,7 +255,11 @@ describe("handleNewBooking", () => { expectBookingCreatedWebhookToHaveBeenFired({ booker, - organizer, + organizer: { + ...organizer, + ...(orgProfile?.username ? + { usernameInOrg: orgProfile?.username } : null), + }, location: BookingLocations.CalVideo, subscriberUrl: "http://my-webhook.example.com", videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`, @@ -263,7 +276,7 @@ describe("handleNewBooking", () => { 3. Should fallback to creating the booking in the first connected Calendar when neither event nor organizer has a destination calendar - This doesn't practically happen because organizer is always required to have a schedule set 3. Should trigger BOOKING_CREATED webhook `, - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const booker = getBooker({ @@ -424,7 +437,7 @@ describe("handleNewBooking", () => { 3. Should fallback to create a booking in the Organizer Calendar if event doesn't have destination calendar 3. Should trigger BOOKING_CREATED webhook `, - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const booker = getBooker({ @@ -580,7 +593,7 @@ describe("handleNewBooking", () => { test( `an error in creating a calendar event should not stop the booking creation - Current behaviour is wrong as the booking is created but no-one is notified of it`, - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const booker = getBooker({ @@ -702,7 +715,7 @@ describe("handleNewBooking", () => { test( "If destination calendar has no credential ID due to some reason, it should create the event in first connected calendar instead", - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const booker = getBooker({ @@ -869,7 +882,7 @@ describe("handleNewBooking", () => { test( "If destination calendar is there for Google Calendar but there are no Google Calendar credentials but there is an Apple Calendar credential connected, it should create the event in Apple Calendar", - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const booker = getBooker({ @@ -1027,7 +1040,7 @@ describe("handleNewBooking", () => { describe("Event's first location should be used when location is unspecied", () => { test( `should create a successful booking with right location app when event has location option as video client`, - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const booker = getBooker({ @@ -1121,7 +1134,7 @@ describe("handleNewBooking", () => { `should create a successful booking with right location when event's location is not a conferencing app `, //test with inPerson event type - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const booker = getBooker({ @@ -1205,7 +1218,7 @@ describe("handleNewBooking", () => { ); test( `should create a successful booking with organizer default conferencing app when event's location is not set`, - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const booker = getBooker({ @@ -1300,7 +1313,7 @@ describe("handleNewBooking", () => { describe("Video Meeting Creation", () => { test( `should create a successful booking with Zoom if used`, - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const subscriberUrl = "http://my-webhook.example.com"; @@ -1389,7 +1402,7 @@ describe("handleNewBooking", () => { test( `Booking should still be created using calvideo, if error occurs with zoom`, - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const subscriberUrl = "http://my-webhook.example.com"; @@ -2130,7 +2143,7 @@ describe("handleNewBooking", () => { 3. Should trigger BOOKING_REQUESTED webhook 4. Should trigger BOOKING_REQUESTED workflow `, - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const subscriberUrl = "http://my-webhook.example.com"; @@ -2268,7 +2281,7 @@ describe("handleNewBooking", () => { 3. Should trigger BOOKING_REQUESTED webhook 4. Should trigger BOOKING_REQUESTED workflow `, - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const subscriberUrl = "http://my-webhook.example.com"; @@ -2395,7 +2408,7 @@ describe("handleNewBooking", () => { 2. Should send emails to the booker as well as organizer 3. Should trigger BOOKING_CREATED webhook `, - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const booker = getBooker({ @@ -2532,7 +2545,7 @@ describe("handleNewBooking", () => { 3. Should trigger BOOKING_REQUESTED webhook 4. Should trigger BOOKING_REQUESTED workflows `, - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const subscriberUrl = "http://my-webhook.example.com"; @@ -2731,7 +2744,7 @@ describe("handleNewBooking", () => { 2. Should send emails to the booker as well as organizer 3. Should trigger BOOKING_CREATED webhook `, - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const booker = getBooker({ @@ -2858,7 +2871,7 @@ describe("handleNewBooking", () => { 6. Workflow should not trigger before payment is made 7. Workflow triggers once payment is successful `, - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const booker = getBooker({ @@ -3041,7 +3054,7 @@ describe("handleNewBooking", () => { 6. Should trigger BOOKING_REQUESTED workflow 7. Booking should still stay in pending state `, - + async ({ emails }) => { const bookingInitiatedEmail = "booking_initiated@workflow.com"; const handleNewBooking = getNewBookingHandler(); @@ -3221,7 +3234,7 @@ describe("handleNewBooking", () => { ); test( `cannot book same slot multiple times `, - + async ({ emails }) => { const handleNewBooking = getNewBookingHandler(); const booker = getBooker({ diff --git a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/round-robin.test.ts b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/round-robin.test.ts index 727243fce4589b..0c9de8d828004c 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/round-robin.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/round-robin.test.ts @@ -12,15 +12,16 @@ import { getGoogleCalendarCredential, mockCalendarToHaveNoBusySlots, } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { expectBookingCreatedWebhookToHaveBeenFired } from "@calcom/web/test/utils/bookingScenario/expects"; import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; import { describe, test, vi, expect } from "vitest"; -import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { SchedulingType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; +import { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { getNewBookingHandler } from "../getNewBookingHandler"; @@ -34,23 +35,7 @@ describe("Round Robin handleNewBooking", () => { email: "booker@example.com", name: "Booker", }); - const teamMemberOne = [ - { - name: "Team Member One", - username: "other-team-member-1", - timeZone: Timezones["+5:30"], - defaultScheduleId: null, - email: "team-member-one@example.com", - id: 102, - schedule: TestData.schedules.IstMorningShift, - credentials: [getGoogleCalendarCredential()], - selectedCalendars: [TestData.selectedCalendars.google], - destinationCalendar: { - integration: TestData.apps["google-calendar"].type, - externalId: "other-team-member-1@google-calendar.com", - }, - }, - ]; + const organizer = getOrganizer({ name: "Organizer", email: "organizer@example.com", @@ -142,14 +127,14 @@ describe("Round Robin handleNewBooking", () => { ); mockSuccessfulVideoMeetingCreation({ - metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + metadataLookupKey: "dailyvideo", videoMeetingData: { id: "MOCK_ID", password: "MOCK_PASS", url: `http://mock-dailyvideo.example.com/meeting-1`, }, }); - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + mockCalendarToHaveNoBusySlots("googlecalendar", { create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", @@ -334,7 +319,7 @@ describe("Round Robin handleNewBooking", () => { ); mockSuccessfulVideoMeetingCreation({ - metadataLookupKey: appStoreMetadata.dailyvideo.dirName || "dailyvideo", + metadataLookupKey: "dailyvideo", videoMeetingData: { id: "MOCK_ID", password: "MOCK_PASS", @@ -342,7 +327,7 @@ describe("Round Robin handleNewBooking", () => { }, }); - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + mockCalendarToHaveNoBusySlots("googlecalendar", { create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", @@ -443,7 +428,7 @@ describe("Round Robin handleNewBooking", () => { ); mockSuccessfulVideoMeetingCreation({ - metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + metadataLookupKey: "dailyvideo", videoMeetingData: { id: "MOCK_ID", password: "MOCK_PASS", @@ -451,7 +436,7 @@ describe("Round Robin handleNewBooking", () => { }, }); - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + mockCalendarToHaveNoBusySlots("googlecalendar", { create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", @@ -592,7 +577,7 @@ describe("Round Robin handleNewBooking", () => { ); mockSuccessfulVideoMeetingCreation({ - metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + metadataLookupKey: "dailyvideo", videoMeetingData: { id: "MOCK_ID", password: "MOCK_PASS", @@ -707,8 +692,8 @@ describe("Round Robin handleNewBooking", () => { users: [{ id: teamMembers[0].id }, { id: teamMembers[1].id }], hosts: [ // One host with explicit null groupId, one without groupId property - { userId: teamMembers[0].id, isFixed: false, groupId: null, weight: 100, priority: 1 }, - { userId: teamMembers[1].id, isFixed: false, weight: 100, priority: 1 }, // No groupId property + { userId: teamMembers[0].id, isFixed: false, groupId: null }, + { userId: teamMembers[1].id, isFixed: false }, // No groupId property ], hostGroups: [], // No explicit host groups defined schedule: TestData.schedules.IstWorkHours, @@ -859,7 +844,7 @@ describe("Round Robin handleNewBooking", () => { }, references: [ { - type: appStoreMetadata.dailyvideo.type, + type: "dailyvideo", uid: "MOCK_ID", meetingId: "MOCK_ID", meetingPassword: "MOCK_PASS", @@ -929,4 +914,176 @@ describe("Round Robin handleNewBooking", () => { expect(teamMemberEmails).not.toContain(teamMembers[1].email); }); }); + + describe("Organization Team Events", () => { + test("Organization team booking includes usernameInOrg in webhook payload", async () => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + // Set up organization team members with profiles + const organizationId = 1; + const teamMembers = [ + { + name: "Alice Johnson", + username: "alice", + email: "alice@example.com", + id: 102, + timeZone: Timezones["+5:30"], + defaultScheduleId: null, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + profiles: [ + { + uid: "profile-alice", + username: "alice-acme", + organizationId: organizationId, + }, + ], + }, + { + name: "Bob Smith", + username: "bob", + email: "bob@example.com", + id: 103, + timeZone: Timezones["+5:30"], + defaultScheduleId: null, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + profiles: [ + { + uid: "profile-bob", + username: "bob-acme", + organizationId: organizationId, + }, + ], + }, + ]; + + const organizer = getOrganizer({ + name: "Team Lead", + email: "lead@example.com", + id: 101, + defaultScheduleId: null, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "lead@google-calendar.com", + }, + }); + + const subscriberUrl = "http://test-webhook.example.com"; + const webhooks = [ + { + id: "WEBHOOK_TEST_ID", + appId: null, + userId: null, + teamId: 1, + eventTypeId: 1, + active: true, + eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED], + subscriberUrl, + }, + ]; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + schedulingType: SchedulingType.ROUND_ROBIN, + length: 30, + teamId: 1, + team: { + id: 1, + parentId: organizationId, // Organization team + }, + users: [{ id: teamMembers[0].id }, { id: teamMembers[1].id }], + hosts: [ + { userId: teamMembers[0].id, isFixed: false }, + { userId: teamMembers[1].id, isFixed: false }, + ], + schedule: TestData.schedules.IstWorkHours, + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "team@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: teamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + webhooks, + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${getDate({ dateIncrement: 1 }).dateString}T10:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T10:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + // Verify booking was created successfully + expect(createdBooking).toBeDefined(); + expect(createdBooking.responses).toEqual( + expect.objectContaining({ + email: booker.email, + name: booker.name, + }) + ); + + // Get the organizer for webhook verification + const organizerUserId = createdBooking.userId; + const organizerTeamMember = teamMembers.find((member) => member.id === organizerUserId); + const organizerProfile = organizerTeamMember?.profiles?.[0]; + + // Verify webhook includes usernameInOrg for organization team events + // This test will initially fail due to the bug + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer: { + email: organizerTeamMember?.email || "unknown@example.com", + name: organizerTeamMember?.name || "Unknown", + username: organizerTeamMember?.username, + usernameInOrg: organizerProfile?.username, // This should be present but will be undefined due to the bug + }, + location: BookingLocations.CalVideo, + subscriberUrl, + }); + }); + }); }); diff --git a/packages/features/bookings/lib/payment/getBooking.ts b/packages/features/bookings/lib/payment/getBooking.ts index 9a81952b397c12..fd0f0dd01497f0 100644 --- a/packages/features/bookings/lib/payment/getBooking.ts +++ b/packages/features/bookings/lib/payment/getBooking.ts @@ -173,15 +173,16 @@ export async function getBooking(bookingId: number) { }), organizer: { email: booking?.userPrimaryEmail ?? user.email, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion name: user.name!, + username: user.username || undefined, + usernameInOrg: organizerOrganizationProfile?.username || undefined, timeZone: user.timeZone, timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat), language: { translate: t, locale: user.locale ?? "en" }, id: user.id, }, hideOrganizerEmail: booking.eventType?.hideOrganizerEmail, - team: !!booking.eventType?.team + team: booking.eventType?.team ? { name: booking.eventType.team.name, id: booking.eventType.team.id, diff --git a/packages/features/webhooks/components/WebhookForm.tsx b/packages/features/webhooks/components/WebhookForm.tsx index 146b29202168e9..5ee110239477fd 100644 --- a/packages/features/webhooks/components/WebhookForm.tsx +++ b/packages/features/webhooks/components/WebhookForm.tsx @@ -164,6 +164,18 @@ function getWebhookVariables(t: (key: string) => string) { type: "String", description: t("webhook_organizer_locale"), }, + { + name: "organizer.username", + variable: "{{organizer.username}}", + type: "String", + description: t("webhook_organizer_username"), + }, + { + name: "organizer.usernameInOrg", + variable: "{{organizer.usernameInOrg}}", + type: "String", + description: t("webhook_organizer_username_in_org"), + }, { name: "attendees.0.name", variable: "{{attendees.0.name}}", diff --git a/packages/features/webhooks/lib/sendPayload.ts b/packages/features/webhooks/lib/sendPayload.ts index 07a754b89beee6..835636e2bf8894 100644 --- a/packages/features/webhooks/lib/sendPayload.ts +++ b/packages/features/webhooks/lib/sendPayload.ts @@ -142,6 +142,7 @@ function getZapierPayload(data: WithUTCOffsetType { email: booking?.userPrimaryEmail || booking.user?.email || "Email-less", name: booking.user?.name || "Nameless", username: booking.user?.username || undefined, + usernameInOrg: organizerOrganizationProfile?.username || undefined, timeZone: booking.user?.timeZone || "Europe/London", timeFormat: getTimeFormatStringFromUserTimeFormat(booking.user?.timeFormat), language: { translate: tOrganizer, locale: booking.user?.locale ?? "en" }, diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 2e7f9ed3cb0297..f4344dae23997f 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -36,6 +36,7 @@ export type Person = { timeZone: string; language: { translate: TFunction; locale: string }; username?: string; + usernameInOrg?: string; id?: number; bookingId?: number | null; locale?: string | null;