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:
co-authored by
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
1e226a40d1
commit
9c97b2aa6d
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+126
-15
@@ -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({
|
||||
|
||||
+185
-28
@@ -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" },
|
||||
|
||||
Vendored
+1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user