feat: add usernameInOrg field to webhook organizer payload for organization users (#23246)

* feat: add usernameInOrg field to webhook organizer payload for organization users

- Add usernameInOrg field to CalendarEventBuilder organizer interface
- Update handleNewBooking to pass organizerOrganizationProfile.username as usernameInOrg
- Include usernameInOrg in webhook payload generation (sendPayload.ts)
- Add webhook form variable for usernameInOrg with translation
- Update Person type to include usernameInOrg field
- Add test case for organization user webhook verification
- Maintain backward compatibility by keeping existing username field unchanged

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* feat: add usernameInOrg to additional webhook sending locations

- Update handleCancelBooking.ts to include usernameInOrg in organizer payload
- Update confirm.handler.ts to include usernameInOrg for booking confirmations
- Update getBooking.ts to include usernameInOrg in payment-related webhooks
- Maintain backward compatibility with existing username field
- All webhook sending locations now include organization profile username

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fixes

* Add subteam event test for usernameInOrg

* fix eslitn issues

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Hariom Balhara
2025-10-06 19:51:04 +05:30
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 1e226a40d1
commit 9c97b2aa6d
14 changed files with 581 additions and 243 deletions
@@ -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",
@@ -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<VideoApiAdapter>;
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<string, any> = {};
const mockVideoAdapterRegistry: Record<string, unknown> = {};
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<any>();
const actual = await importOriginal<typeof import("@calcom/lib/crypto")>();
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<string, string>;
[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<string, any>;
data: PaymentData;
externalId: string;
paymentOption?: PaymentOption;
};
@@ -258,7 +271,7 @@ export type InputEventType = {
bookingLimits?: IntervalLimit;
durationLimits?: IntervalLimit;
owner?: number;
metadata?: any;
metadata?: z.infer<typeof EventTypeMetaDataSchema>;
rescheduleWithSameRoundRobinHost?: boolean;
restrictionSchedule?: {
create: {
@@ -296,6 +309,7 @@ type WhiteListedBookingProps = {
email: string;
phoneNumber?: string;
bookingSeat?: AttendeeBookingSeatInput | null;
timeZone?: string;
}[];
references?: (Omit<ReturnType<typeof getMockBookingReference>, "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<typeof EventTypeMetaDataSchema>;
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<NewCalendarEventType> {
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<Calendar["createEvent"]>
): Promise<NewCalendarEventType> {
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<Calendar["updateEvent"]>
): Promise<NewCalendarEventType> {
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<NewCalendarEventType> {
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<EventBusyDate[]> => {
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<Calendar["deleteEvent"]>) => {
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<EventBusyDate[]> => {
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<NonNullableVideoApiAdapter["createMeeting"]>) => {
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<NonNullableVideoApiAdapter["updateMeeting"]>) => {
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<NonNullableVideoApiAdapter["deleteMeeting"]>) => {
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",
+20 -7
View File
@@ -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<string, unknown>;
}) {
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,
});
}
@@ -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,
@@ -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,
@@ -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,
},
},
});
});
});
@@ -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),
@@ -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({
@@ -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,
});
});
});
});
@@ -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,
@@ -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}}",
@@ -142,6 +142,7 @@ function getZapierPayload(data: WithUTCOffsetType<EventPayloadType & { createdAt
cancellationReason: data.cancellationReason,
user: {
username: data.organizer.username,
usernameInOrg: data.organizer.usernameInOrg,
name: data.organizer.name,
email: data.organizer.email,
timeZone: data.organizer.timeZone,
@@ -220,6 +220,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
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" },
+1
View File
@@ -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;