diff --git a/packages/app-store/locations.ts b/packages/app-store/locations.ts index c5a911554e..e7da1d8e15 100644 --- a/packages/app-store/locations.ts +++ b/packages/app-store/locations.ts @@ -1,3 +1,6 @@ +/** + * TODO: Consolidate this file with BookingLocationService and add tests + */ import type { TFunction } from "i18next"; import { z } from "zod"; @@ -58,7 +61,7 @@ export type EventLocationTypeFromApp = Ensure< export type EventLocationType = DefaultEventLocationType | EventLocationTypeFromApp; export const DailyLocationType = "integrations:daily"; - +export const CalVideoLocationType = DailyLocationType; export const MeetLocationType = "integrations:google:meet"; export const MSTeamsLocationType = "integrations:office365_video"; @@ -380,7 +383,7 @@ export const getLocationValueForDB = ( eventLocations: LocationObject[] ) => { let bookingLocation = bookingLocationTypeOrValue; - let conferenceCredentialId = undefined; + let conferenceCredentialId: number | undefined = undefined; eventLocations.forEach((location) => { if (location.type === bookingLocationTypeOrValue) { diff --git a/packages/app-store/webex/api/add.ts b/packages/app-store/webex/api/add.ts index 30115d3251..703a19f68e 100644 --- a/packages/app-store/webex/api/add.ts +++ b/packages/app-store/webex/api/add.ts @@ -1,7 +1,7 @@ import type { NextApiRequest } from "next"; import { stringify } from "querystring"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants"; import { defaultHandler } from "@calcom/lib/server/defaultHandler"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; import prisma from "@calcom/prisma"; @@ -26,7 +26,7 @@ async function handler(req: NextApiRequest) { const params = { response_type: "code", client_id, - redirect_uri: `${WEBAPP_URL}/api/integrations/${config.slug}/callback`, + redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/${config.slug}/callback`, scope: "spark:kms meeting:schedules_read meeting:schedules_write", //should be "A space-separated list of scopes being requested by your integration" state: "", }; diff --git a/packages/app-store/webex/api/callback.ts b/packages/app-store/webex/api/callback.ts index 2daf4aa333..a43a89784c 100644 --- a/packages/app-store/webex/api/callback.ts +++ b/packages/app-store/webex/api/callback.ts @@ -1,6 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; -import { WEBAPP_URL } from "@calcom/lib/constants"; +import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import prisma from "@calcom/prisma"; @@ -17,7 +17,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) /** @link https://developer.webex.com/docs/integrations#getting-an-access-token **/ - const redirectUri = encodeURI(`${WEBAPP_URL}/api/integrations/${config.slug}/callback`); + const redirectUri = encodeURI(`${WEBAPP_URL_FOR_OAUTH}/api/integrations/${config.slug}/callback`); const params = new URLSearchParams([ ["grant_type", "authorization_code"], ["client_id", client_id], diff --git a/packages/features/ee/round-robin/handleRescheduleEventManager.ts b/packages/features/ee/round-robin/handleRescheduleEventManager.ts index c0822d4fa7..4675954b2f 100644 --- a/packages/features/ee/round-robin/handleRescheduleEventManager.ts +++ b/packages/features/ee/round-robin/handleRescheduleEventManager.ts @@ -73,6 +73,17 @@ export const handleRescheduleEventManager = async ({ const results = updateManager.results ?? []; + const calVideoResult = results.find((result) => result.type === "daily_video"); + // Check if Cal Video Creation Failed - That is the fallback for Cal.com and is expected to always work + if (calVideoResult && !calVideoResult.success) { + handleRescheduleEventManager.error("Cal Video creation failed", { + error: calVideoResult.error, + bookingLocation, + }); + // This happens only when Cal Video is down + throw new Error("Failed to set video conferencing link, but the meeting has been rescheduled"); + } + const { metadata: videoMetadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({ results: results, }); @@ -80,7 +91,6 @@ export const handleRescheduleEventManager = async ({ let videoCallUrl = _videoCallUrl; let metadata: AdditionalInformation = {}; metadata = videoMetadata; - if (results.length) { // Handle Google Meet results if (bookingLocation === MeetLocationType) { @@ -154,6 +164,7 @@ export const handleRescheduleEventManager = async ({ ? calendarResult?.updatedEvent[0]?.iCalUID : calendarResult?.updatedEvent?.iCalUID || undefined; } + const newReferencesToCreate = structuredClone(updateManager.referencesToCreate); await BookingReferenceRepository.replaceBookingReferences({ diff --git a/packages/features/ee/round-robin/roundRobinManualReassignment.test.ts b/packages/features/ee/round-robin/roundRobinManualReassignment.test.ts index 385fa46a6a..ecdda111db 100644 --- a/packages/features/ee/round-robin/roundRobinManualReassignment.test.ts +++ b/packages/features/ee/round-robin/roundRobinManualReassignment.test.ts @@ -17,65 +17,238 @@ import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAn import { describe, vi, expect } from "vitest"; +import { OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations"; import { BookingRepository } from "@calcom/lib/server/repository/booking"; import { SchedulingType, BookingStatus, WorkflowMethods } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; vi.mock("@calcom/lib/EventManager"); +vi.mock("@calcom/app-store/utils", () => ({ + getAppFromSlug: vi.fn(), +})); -const testDestinationCalendar = { - integration: "test-calendar", - externalId: "test-calendar", +// Type definitions +type TestUserMetadata = { + defaultConferencingApp?: { + appSlug: string; + appLink?: string; + }; }; -const testUsers = [ - { - id: 1, - name: "user-1", - timeZone: "Asia/Kolkata", - username: "host-1", - email: "host1@test.com", - schedules: [TestData.schedules.IstWorkHours], - destinationCalendar: testDestinationCalendar, - }, - { - id: 2, - name: "user-2", - timeZone: "Asia/Kolkata", - username: "host-2", - email: "host2@test.com", - schedules: [TestData.schedules.IstWorkHours], - }, - { - id: 3, - name: "user-3", - timeZone: "Asia/Kolkata", - username: "host-3", - email: "host3@test.com", - schedules: [TestData.schedules.IstWorkHours], - }, -]; +type TestUser = { + id: number; + name: string; + timeZone: string; + username: string; + email: string; + schedules: (typeof TestData.schedules.IstWorkHours)[]; + destinationCalendar?: { integration: string; externalId: string }; + metadata?: TestUserMetadata; +}; + +type MockBookingAttendee = ReturnType; + +// Test Data Builders +const createTestUser = (overrides?: { + id?: number; + name?: string; + username?: string; + email?: string; + destinationCalendar?: { integration: string; externalId: string }; + metadata?: TestUserMetadata; +}) => ({ + id: overrides?.id ?? 1, + name: overrides?.name ?? `user-${overrides?.id ?? 1}`, + timeZone: "Asia/Kolkata", + username: overrides?.username ?? `host-${overrides?.id ?? 1}`, + email: overrides?.email ?? `host${overrides?.id ?? 1}@test.com`, + schedules: [TestData.schedules.IstWorkHours], + destinationCalendar: overrides?.destinationCalendar, + metadata: overrides?.metadata, + defaultScheduleId: 1, + credentials: [], + selectedCalendars: [], + weekStart: "Monday" as const, + teams: [], + availability: [], + profiles: [], + outOfOffice: undefined, + smsLockState: undefined, + completedOnboarding: true, + locked: false, + organizationId: null, +}); + +const createTestDestinationCalendar = (overrides?: { integration?: string; externalId?: string }) => ({ + integration: overrides?.integration ?? "test-calendar", + externalId: overrides?.externalId ?? "test-calendar", +}); + +const createTestBooking = async (params: { + eventTypeId: number; + userId: number; + bookingId: number; + bookingUid: string; + attendees?: MockBookingAttendee[]; + location?: string; +}) => { + const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 }); + + return { + id: params.bookingId, + eventTypeId: params.eventTypeId, + userId: params.userId, + uid: params.bookingUid, + status: BookingStatus.ACCEPTED, + startTime: `${dateStringPlusOne}T05:00:00.000Z`, + endTime: `${dateStringPlusOne}T05:15:00.000Z`, + location: params.location ?? null, + attendees: params.attendees ?? [ + getMockBookingAttendee({ + id: 2, + name: "attendee", + email: "attendee@test.com", + locale: "en", + timeZone: "Asia/Kolkata", + }), + ], + }; +}; + +const createRoundRobinEventType = (params: { + id: number; + slug: string; + users: TestUser[]; + hosts?: { userId: number; isFixed: boolean }[]; + locations?: { type: string; credentialId?: number }[]; + teamId?: number; + schedulingType?: SchedulingType; + parentId?: number; +}) => ({ + id: params.id, + slug: params.slug, + schedulingType: params.schedulingType ?? SchedulingType.ROUND_ROBIN, + length: 45, + users: params.users.map((user) => ({ id: user.id })), + hosts: params.hosts ?? params.users.map((user) => ({ userId: user.id, isFixed: false })), + locations: params.locations, + teamId: params.teamId, + parentId: params.parentId, +}); + +// Assertion Helpers +const expectEventManagerCalledWith = ( + spy: ReturnType, + expectedParams: { + location?: string; + uid: string; + changedOrganizer: boolean; + destinationCalendars?: Array<{ integration: string; externalId: string }>; + } +) => { + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + expectedParams.location + ? expect.objectContaining({ location: expectedParams.location }) + : expect.any(Object), + expectedParams.uid, + undefined, + expectedParams.changedOrganizer, + expectedParams.destinationCalendars ?? expect.any(Array) + ); +}; + +// Mock Helpers +type MockEventManagerConfig = { + failConferencing?: boolean; +}; + +type ConferenceResult = { + type: string; + success: boolean; + uid: string; + originalEvent: Record; + error?: string; + updatedEvent?: { + url: string; + }; +}; + +const mockEventManagerReschedule = async (config?: MockEventManagerConfig) => { + const EventManager = (await import("@calcom/lib/EventManager")).default; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spy = vi.spyOn(EventManager.prototype as any, "reschedule"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + spy.mockImplementation(async (event: any) => { + const location = event.location; + const results: ConferenceResult[] = []; + + // Check if location is a static link (starts with http:// or https://) + const isStaticLink = location?.startsWith("http://") || location?.startsWith("https://"); + + if (isStaticLink) { + // For static links, no conferencing result is generated + return { referencesToCreate: [] }; + } + + // Handle conferencing based on location type + if (location?.includes("integrations:")) { + const conferenceType = location.split(":")[1]; + + if (config?.failConferencing) { + results.push({ + type: `${conferenceType}_video`, + success: false, + error: `Failed to create ${conferenceType} room`, + uid: event.uid || "test-uid", + originalEvent: {}, + }); + } else { + results.push({ + type: `${conferenceType}_video`, + success: true, + uid: event.uid || "test-uid", + originalEvent: {}, + updatedEvent: { + url: + conferenceType === "zoom" + ? "https://zoom.us/j/123456789" + : conferenceType === "google" + ? "https://meet.google.com/test-meeting" + : `https://${conferenceType}.co/test-room`, + }, + }); + } + } + + return { results, referencesToCreate: [] }; + }); + + return spy; +}; + +const mockGetAppFromSlug = async () => { + const { getAppFromSlug } = await import("@calcom/app-store/utils"); + return vi.mocked(getAppFromSlug); +}; describe("roundRobinManualReassignment test", () => { setupAndTeardown(); - test("manually reassign round robin organizer", async ({ emails }) => { + test("should reassign round robin booking to new host and update workflows", async ({ emails }) => { const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default; - const EventManager = (await import("@calcom/lib/EventManager")).default; + const eventManagerRescheduleSpy = await mockEventManagerReschedule(); - const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule"); - eventManagerSpy.mockResolvedValue({ referencesToCreate: [] }); + const testDestinationCalendar = createTestDestinationCalendar(); + const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar }); + const newHost = createTestUser({ id: 2 }); + const users = [originalHost, newHost, createTestUser({ id: 3 })]; - const users = testUsers; - const originalHost = users[0]; - const newHost = users[1]; - - const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 }); const { dateString: dateStringPlusTwo } = getDate({ dateIncrement: 2 }); - const bookingToReassignUid = "booking-to-reassign"; - const bookingData = await createBookingScenario( + await createBookingScenario( getScenarioData({ workflows: [ { @@ -83,48 +256,34 @@ describe("roundRobinManualReassignment test", () => { trigger: "NEW_EVENT", action: "EMAIL_HOST", template: "REMINDER", - activeEventTypeId: 1, + activeOn: [1], }, ], eventTypes: [ - { + createRoundRobinEventType({ id: 1, slug: "round-robin-event", - schedulingType: SchedulingType.ROUND_ROBIN, - length: 45, - users: users.map((user) => ({ id: user.id })), - hosts: users.map((user) => ({ userId: user.id, isFixed: false })), - }, + users, + }), ], bookings: [ - { - id: 123, + await createTestBooking({ eventTypeId: 1, userId: originalHost.id, - uid: bookingToReassignUid, - status: BookingStatus.ACCEPTED, - startTime: `${dateStringPlusOne}T05:00:00.000Z`, - endTime: `${dateStringPlusOne}T05:15:00.000Z`, - attendees: [ - getMockBookingAttendee({ - id: 2, - name: "attendee", - email: "attendee@test.com", - locale: "en", - timeZone: "Asia/Kolkata", - }), - ], - }, + bookingId: 123, + bookingUid: bookingToReassignUid, + }), ], organizer: originalHost, usersApartFromOrganizer: users.slice(1), }) ); + await addWorkflowReminders([ { bookingUid: bookingToReassignUid, method: WorkflowMethods.EMAIL, - scheduledDate: dateStringPlusTwo, + scheduledDate: new Date(dateStringPlusTwo), scheduled: true, workflowStepId: 1, workflowId: 1, @@ -135,16 +294,14 @@ describe("roundRobinManualReassignment test", () => { bookingId: 123, newUserId: newHost.id, orgId: null, + reassignedById: 1, }); - expect(eventManagerSpy).toBeCalledTimes(1); - expect(eventManagerSpy).toHaveBeenCalledWith( - expect.any(Object), - bookingToReassignUid, - undefined, - true, - expect.arrayContaining([expect.objectContaining(testDestinationCalendar)]) - ); + expectEventManagerCalledWith(eventManagerRescheduleSpy, { + uid: bookingToReassignUid, + changedOrganizer: true, + destinationCalendars: expect.arrayContaining([expect.objectContaining(testDestinationCalendar)]), + }); expectBookingToBeInDatabase({ uid: bookingToReassignUid, @@ -160,21 +317,73 @@ describe("roundRobinManualReassignment test", () => { expectWorkflowToBeTriggered({ emailsToReceive: [newHost.email], emails }); }); - test("Manually reassign round robin host with fixed host as organizer", async () => { + test("should include conferenceCredentialId when reassigning booking with with fixed host as organizer", async () => { const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default; - const EventManager = (await import("@calcom/lib/EventManager")).default; + const eventManagerRescheduleSpy = await mockEventManagerReschedule(); - const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule"); + const testDestinationCalendar = createTestDestinationCalendar(); + const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar }); + const newHost = createTestUser({ id: 2 }); + const users = [originalHost, newHost]; - const users = testUsers; + const bookingToReassignUid = "booking-with-conference-credential"; + const conferenceCredentialId = 999; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + createRoundRobinEventType({ + id: 1, + slug: "round-robin-event", + users, + locations: [ + { + type: "integrations:zoom", + credentialId: conferenceCredentialId, + }, + ], + }), + ], + bookings: [ + await createTestBooking({ + eventTypeId: 1, + userId: originalHost.id, + bookingId: 123, + bookingUid: bookingToReassignUid, + location: "integrations:zoom", + }), + ], + organizer: originalHost, + usersApartFromOrganizer: users.slice(1), + }) + ); + + await roundRobinManualReassignment({ + bookingId: 123, + newUserId: newHost.id, + orgId: null, + reassignedById: 1, + }); + + // Verify that EventManager.reschedule was called with an event containing conferenceCredentialId + expect(eventManagerRescheduleSpy).toHaveBeenCalledTimes(1); + const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((calledEvent as any).conferenceCredentialId).toBe(conferenceCredentialId); + }); + + test("should reassign round robin attendee while keeping fixed host as organizer", async () => { + const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default; + const eventManagerRescheduleSpy = await mockEventManagerReschedule(); + + const testDestinationCalendar = createTestDestinationCalendar(); + const fixedHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar }); + const currentRRHost = createTestUser({ id: 2 }); + const newHost = createTestUser({ id: 3 }); + const users = [fixedHost, currentRRHost, newHost]; const bookingToReassignUid = "booking-to-reassign"; - const fixedHost = users[0]; - const currentRRHost = users[1]; - const newHost = users[2]; - const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 }); - await createBookingScenario( getScenarioData({ workflows: [ @@ -186,27 +395,22 @@ describe("roundRobinManualReassignment test", () => { }, ], eventTypes: [ - { + createRoundRobinEventType({ id: 1, slug: "round-robin-event", - schedulingType: SchedulingType.ROUND_ROBIN, - length: 45, - users: users.map((user) => ({ id: user.id })), + users, hosts: users.map((user) => ({ userId: user.id, isFixed: user.id === fixedHost.id, })), - }, + }), ], bookings: [ - { - id: 123, + await createTestBooking({ eventTypeId: 1, userId: fixedHost.id, - uid: bookingToReassignUid, - status: BookingStatus.ACCEPTED, - startTime: `${dateStringPlusOne}T05:00:00.000Z`, - endTime: `${dateStringPlusOne}T05:15:00.000Z`, + bookingId: 123, + bookingUid: bookingToReassignUid, attendees: [ getMockBookingAttendee({ id: 1, @@ -223,7 +427,7 @@ describe("roundRobinManualReassignment test", () => { timeZone: currentRRHost.timeZone, }), ], - }, + }), ], organizer: fixedHost, usersApartFromOrganizer: users.slice(1), @@ -234,65 +438,59 @@ describe("roundRobinManualReassignment test", () => { bookingId: 123, newUserId: newHost.id, orgId: null, + reassignedById: 1, }); - expect(eventManagerSpy).toBeCalledTimes(1); + expectEventManagerCalledWith(eventManagerRescheduleSpy, { + uid: bookingToReassignUid, + changedOrganizer: false, + }); - // Ensure organizer stays the same expectBookingToBeInDatabase({ uid: bookingToReassignUid, userId: fixedHost.id, }); - const bookingRepo = new BookingRepository(prismaMock); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bookingRepo = new BookingRepository(prismaMock as any); const attendees = await bookingRepo.getBookingAttendees(123); expect(attendees.some((attendee) => attendee.email === currentRRHost.email)).toBe(false); expect(attendees.some((attendee) => attendee.email === newHost.email)).toBe(true); }); - test("sends cancellation email to previous RR host when reassigning", async ({ emails }) => { + test("should send cancellation email to previous round robin attendee when reassigning", async () => { const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default; - const EventManager = (await import("@calcom/lib/EventManager")).default; - - const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule"); - eventManagerSpy.mockResolvedValue({ referencesToCreate: [] }); + await mockEventManagerReschedule(); const sendRoundRobinCancelledEmailsAndSMSSpy = vi.spyOn( await import("@calcom/emails"), "sendRoundRobinCancelledEmailsAndSMS" ); - const users = testUsers; - const originalHost = users[0]; - const previousRRHost = users[1]; - const newHost = users[2]; - - const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 }); + const testDestinationCalendar = createTestDestinationCalendar(); + const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar }); + const previousRRHost = createTestUser({ id: 2 }); + const newHost = createTestUser({ id: 3 }); + const users = [originalHost, previousRRHost, newHost]; const bookingToReassignUid = "booking-to-reassign-with-previous-rr-host"; await createBookingScenario( getScenarioData({ eventTypes: [ - { + createRoundRobinEventType({ id: 1, slug: "round-robin-event", - schedulingType: SchedulingType.ROUND_ROBIN, - length: 45, - users: users.map((user) => ({ id: user.id })), - hosts: users.map((user) => ({ userId: user.id, isFixed: false })), - }, + users, + }), ], bookings: [ - { - id: 124, + await createTestBooking({ eventTypeId: 1, userId: originalHost.id, - uid: bookingToReassignUid, - status: BookingStatus.ACCEPTED, - startTime: `${dateStringPlusOne}T05:00:00.000Z`, - endTime: `${dateStringPlusOne}T05:15:00.000Z`, + bookingId: 124, + bookingUid: bookingToReassignUid, attendees: [ getMockBookingAttendee({ id: 2, @@ -309,7 +507,7 @@ describe("roundRobinManualReassignment test", () => { timeZone: previousRRHost.timeZone, }), ], - }, + }), ], organizer: originalHost, usersApartFromOrganizer: users.slice(1), @@ -320,8 +518,358 @@ describe("roundRobinManualReassignment test", () => { bookingId: 124, newUserId: newHost.id, orgId: null, + reassignedById: 1, }); expect(sendRoundRobinCancelledEmailsAndSMSSpy).toHaveBeenCalledTimes(1); }); }); + +describe("roundRobinManualReassignment - Location Changes", () => { + setupAndTeardown(); + + const createConferencingUsers = () => { + return { + googleMeetHost: createTestUser({ + id: 1, + destinationCalendar: { + integration: "google_calendar", + externalId: "test-calendar", + }, + metadata: { + defaultConferencingApp: { + appSlug: "google-meet", + }, + }, + }), + zoomHost: createTestUser({ + id: 2, + metadata: { + defaultConferencingApp: { + appSlug: "zoom", + }, + }, + }), + noDefaultConferencingAppHost: createTestUser({ + id: 3, + metadata: {}, + }), + staticVideoLinkHost: createTestUser({ + id: 4, + metadata: { + defaultConferencingApp: { + appSlug: "custom-video", + appLink: "https://custom-video.com/room123", + }, + }, + }), + }; + }; + + const mockAppStore = async (config?: { googleMeet?: boolean; zoom?: boolean; customVideo?: boolean }) => { + const getAppFromSlug = await mockGetAppFromSlug(); + getAppFromSlug.mockImplementation((slug?: string) => { + if (slug === "google-meet" && config?.googleMeet !== false) { + return { + appData: { + location: { + type: "integrations:google:meet", + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } + if (slug === "zoom" && config?.zoom !== false) { + return { + appData: { + location: { + type: "integrations:zoom", + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } + if (slug === "custom-video" && config?.customVideo !== false) { + return { + appData: { + location: { + type: "integrations:custom-video", + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + } + return undefined; + }); + return getAppFromSlug; + }; + + describe("when event has Organizer's Default Conferencing App location", () => { + test("should update location from Google Meet to Zoom when reassigning between hosts with different default conferencing apps", async () => { + const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default; + const eventManagerRescheduleSpy = await mockEventManagerReschedule(); + + await mockAppStore({ googleMeet: true, zoom: true }); + + const users = createConferencingUsers(); + const googleMeetHost = users.googleMeetHost; + const zoomHost = users.zoomHost; + + const bookingToReassignUid = "booking-location-change-test"; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + createRoundRobinEventType({ + id: 1, + slug: "round-robin-event", + teamId: 1, + users: Object.values(users), + locations: [{ type: OrganizerDefaultConferencingAppType }, { type: "integrations:daily" }], + }), + ], + bookings: [ + await createTestBooking({ + eventTypeId: 1, + userId: googleMeetHost.id, + bookingId: 125, + bookingUid: bookingToReassignUid, + location: "integrations:google:meet", + }), + ], + organizer: googleMeetHost, + usersApartFromOrganizer: Object.values(users).slice(1), + }) + ); + + await roundRobinManualReassignment({ + bookingId: 125, + newUserId: zoomHost.id, + orgId: null, + reassignReason: "Host unavailable", + reassignedById: 999, + }); + + expectEventManagerCalledWith(eventManagerRescheduleSpy, { + location: "integrations:zoom", + uid: bookingToReassignUid, + changedOrganizer: true, + destinationCalendars: expect.arrayContaining([ + expect.objectContaining({ + integration: "google_calendar", + externalId: "test-calendar", + }), + ]), + }); + + expectBookingToBeInDatabase({ + uid: bookingToReassignUid, + userId: zoomHost.id, + }); + }); + + test("should fallback to first available event location in Event Type when new host has no default conferencing app", async () => { + const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default; + const eventManagerRescheduleSpy = await mockEventManagerReschedule(); + + // (await mockGetAppFromSlug()).mockReturnValue(undefined); + + const users = createConferencingUsers(); + const googleMeetHost = users.googleMeetHost; + const noDefaultConferencingAppHost = users.noDefaultConferencingAppHost; + + const bookingToReassignUid = "booking-fallback-location-test"; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + createRoundRobinEventType({ + id: 2, + slug: "round-robin-event-fallback", + users: Object.values(users), + locations: [ + { type: OrganizerDefaultConferencingAppType }, + { type: "integrations:first-available" }, + ], + }), + ], + bookings: [ + await createTestBooking({ + eventTypeId: 2, + userId: googleMeetHost.id, + bookingId: 126, + bookingUid: bookingToReassignUid, + location: "integrations:google:meet", + }), + ], + organizer: googleMeetHost, + usersApartFromOrganizer: Object.values(users).slice(1), + }) + ); + + await roundRobinManualReassignment({ + bookingId: 126, + newUserId: noDefaultConferencingAppHost.id, + orgId: null, + reassignReason: "Host unavailable", + reassignedById: 999, + }); + + const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((calledEvent as any).location).toBe("integrations:first-available"); + }); + + test("should update location from Google Meet to static video link when new host has static video link app", async () => { + const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default; + const eventManagerRescheduleSpy = await mockEventManagerReschedule(); + + await mockAppStore({ customVideo: true }); + + const users = createConferencingUsers(); + const googleMeetHost = users.googleMeetHost; + const staticVideoLinkHost = users.staticVideoLinkHost; + + const bookingToReassignUid = "booking-static-link-test"; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + createRoundRobinEventType({ + id: 4, + slug: "managed-event", + // This is a managed event type child which allows Organizer's Default Conferencing App + parentId: 1, + users: Object.values(users), + locations: [{ type: OrganizerDefaultConferencingAppType }], + }), + ], + bookings: [ + await createTestBooking({ + eventTypeId: 4, + userId: googleMeetHost.id, + bookingId: 128, + bookingUid: bookingToReassignUid, + location: "integrations:google:meet", + }), + ], + organizer: googleMeetHost, + usersApartFromOrganizer: Object.values(users).slice(1), + }) + ); + + await roundRobinManualReassignment({ + bookingId: 128, + newUserId: staticVideoLinkHost.id, + orgId: null, + reassignReason: "Host unavailable", + reassignedById: 999, + }); + + const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((calledEvent as any).location).toBe("https://custom-video.com/room123"); + }); + + test("should update location from Static Video Link to Google Meet when new host has Google Meet app", async () => { + const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default; + const eventManagerRescheduleSpy = await mockEventManagerReschedule(); + + await mockAppStore({ googleMeet: true }); + + const users = createConferencingUsers(); + const staticVideoLinkHost = users.staticVideoLinkHost; + const googleMeetHost = users.googleMeetHost; + + const bookingToReassignUid = "booking-static-link-to-google-meet-test"; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + createRoundRobinEventType({ + id: 3, + slug: "round-robin-event-error", + users: Object.values(users), + teamId: 1, + locations: [{ type: OrganizerDefaultConferencingAppType }], + }), + ], + bookings: [ + await createTestBooking({ + eventTypeId: 3, + userId: staticVideoLinkHost.id, + bookingId: 127, + bookingUid: bookingToReassignUid, + location: "https://custom-video.com/room123", + }), + ], + organizer: staticVideoLinkHost, + usersApartFromOrganizer: Object.values(users).filter((user) => user.id !== staticVideoLinkHost.id), + }) + ); + + await roundRobinManualReassignment({ + bookingId: 127, + newUserId: googleMeetHost.id, + orgId: null, + reassignReason: "Host unavailable", + reassignedById: 999, + }); + + const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((calledEvent as any).location).toBe("integrations:google:meet"); + + await expectBookingToBeInDatabase({ + uid: bookingToReassignUid, + userId: googleMeetHost.id, + location: "integrations:google:meet", + }); + }); + }); + + test("should throw error when Cal Video fallback fails", async () => { + const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default; + await mockEventManagerReschedule({ failConferencing: true }); + + const users = createConferencingUsers(); + const googleMeetHost = users.googleMeetHost; + const noDefaultConferencingAppHost = users.noDefaultConferencingAppHost; + + const bookingToReassignUid = "booking-calvideo-error-test"; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + createRoundRobinEventType({ + id: 3, + slug: "round-robin-event-error", + users: Object.values(users), + locations: [{ type: OrganizerDefaultConferencingAppType }], + }), + ], + bookings: [ + await createTestBooking({ + eventTypeId: 3, + userId: googleMeetHost.id, + bookingId: 127, + bookingUid: bookingToReassignUid, + location: "integrations:google:meet", + }), + ], + organizer: googleMeetHost, + usersApartFromOrganizer: Object.values(users).slice(1), + }) + ); + + await expect( + roundRobinManualReassignment({ + bookingId: 127, + newUserId: noDefaultConferencingAppHost.id, + orgId: null, + reassignReason: "Host unavailable", + reassignedById: 999, + }) + ).rejects.toThrow("Failed to set video conferencing link, but the meeting has been rescheduled"); + }); +}); diff --git a/packages/features/ee/round-robin/roundRobinManualReassignment.ts b/packages/features/ee/round-robin/roundRobinManualReassignment.ts index 87ba3a3d7b..fa337a6217 100644 --- a/packages/features/ee/round-robin/roundRobinManualReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinManualReassignment.ts @@ -1,7 +1,6 @@ // eslint-disable-next-line no-restricted-imports import { cloneDeep } from "lodash"; -import { OrganizerDefaultConferencingAppType, getLocationValueForDB } from "@calcom/app-store/locations"; import dayjs from "@calcom/dayjs"; import { sendRoundRobinCancelledEmailsAndSMS, @@ -28,10 +27,10 @@ import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKey import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj"; import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server/i18n"; +import { BookingLocationService } from "@calcom/lib/server/service/bookingLocationService"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import { prisma } from "@calcom/prisma"; import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums"; -import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; import type { EventTypeMetadata, PlatformClientParams } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; @@ -126,7 +125,6 @@ export const roundRobinManualReassignment = async ({ const originalOrganizer = booking.user; const hasOrganizerChanged = !fixedHost && booking.userId !== newUserId; - const newUser = newUserHost.user; const newUserT = await getTranslation(newUser.locale || "en", "common"); const originalOrganizerT = await getTranslation(originalOrganizer.locale || "en", "common"); @@ -149,7 +147,7 @@ export const roundRobinManualReassignment = async ({ const previousRRHostT = await getTranslation(previousRRHost?.locale || "en", "common"); let bookingLocation = booking.location; - + let conferenceCredentialId: number | null = null; if (hasOrganizerChanged) { const bookingResponses = booking.responses; const responseSchema = getBookingResponsesSchema({ @@ -159,15 +157,20 @@ export const roundRobinManualReassignment = async ({ const responseSafeParse = await responseSchema.safeParseAsync(bookingResponses); const responses = responseSafeParse.success ? responseSafeParse.data : undefined; - if (eventType.locations.some((location) => location.type === OrganizerDefaultConferencingAppType)) { - const newUserMetadataSafeParse = userMetadataSchema.safeParse(newUser.metadata); - const defaultLocationUrl = newUserMetadataSafeParse.success - ? newUserMetadataSafeParse?.data?.defaultConferencingApp?.appLink - : undefined; - const currentBookingLocation = booking.location || "integrations:daily"; - bookingLocation = - defaultLocationUrl || - getLocationValueForDB(currentBookingLocation, eventType.locations).bookingLocation; + // Determine the location for the new host + const isManagedEventType = !!eventType.parentId; + const isTeamEventType = !!eventType.teamId; + + const locationResult = BookingLocationService.getLocationForHost({ + hostMetadata: newUser.metadata, + eventTypeLocations: eventType.locations, + isManagedEventType, + isTeamEventType, + }); + + bookingLocation = locationResult.bookingLocation; + if (locationResult.requiresActualLink) { + conferenceCredentialId = locationResult.conferenceCredentialId; } const newBookingTitle = getEventName({ @@ -219,6 +222,18 @@ export const roundRobinManualReassignment = async ({ }); } + // When organizer hasn't changed, still extract conferenceCredentialId from event type locations + if (!hasOrganizerChanged && bookingLocation) { + const { conferenceCredentialId: extractedCredentialId } = + BookingLocationService.getLocationDetailsFromType({ + locationType: bookingLocation, + eventTypeLocations: eventType.locations || [], + }); + if (extractedCredentialId) { + conferenceCredentialId = extractedCredentialId; + } + } + const destinationCalendar = await getDestinationCalendar({ eventType, booking, @@ -290,6 +305,7 @@ export const roundRobinManualReassignment = async ({ customReplyToEmail: eventType?.customReplyToEmail, location: bookingLocation, ...(platformClientParams ? platformClientParams : {}), + conferenceCredentialId: conferenceCredentialId ?? undefined, }; const credentials = await prisma.credential.findMany({ diff --git a/packages/lib/server/service/bookingLocationService.test.ts b/packages/lib/server/service/bookingLocationService.test.ts new file mode 100644 index 0000000000..f25f794d10 --- /dev/null +++ b/packages/lib/server/service/bookingLocationService.test.ts @@ -0,0 +1,558 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import { BookingLocationService } from "./bookingLocationService"; + +vi.mock("@calcom/app-store/utils", () => ({ + getAppFromSlug: vi.fn(), +})); + +vi.mock("@calcom/app-store/locations", async () => { + const actual = await vi.importActual("@calcom/app-store/locations"); + return { + ...actual, + OrganizerDefaultConferencingAppType: "conferencing", + CalVideoLocationType: "integrations:daily-video", + }; +}); + +const { getAppFromSlug } = await import("@calcom/app-store/utils"); + +describe("BookingLocationService", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getOrganizerDefaultConferencingAppLocation", () => { + describe("Dynamic conferencing apps", () => { + it("should return the app location type for a dynamic app like Google Meet", () => { + const mockMetadata = { + defaultConferencingApp: { + appSlug: "google-meet", + }, + }; + + vi.mocked(getAppFromSlug).mockReturnValue({ + appData: { + location: { + type: "integrations:google:meet", + }, + }, + } as any); + + const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({ + organizerMetadata: mockMetadata, + }); + + expect(result).toEqual({ + conferencingAppLocationType: "integrations:google:meet", + isStaticLinkApp: false, + }); + expect(getAppFromSlug).toHaveBeenCalledWith("google-meet"); + }); + + it("should return the app location type for Zoom", () => { + const mockMetadata = { + defaultConferencingApp: { + appSlug: "zoom", + }, + }; + + vi.mocked(getAppFromSlug).mockReturnValue({ + appData: { + location: { + type: "integrations:zoom", + }, + }, + } as any); + + const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({ + organizerMetadata: mockMetadata, + }); + + expect(result).toEqual({ + conferencingAppLocationType: "integrations:zoom", + isStaticLinkApp: false, + }); + }); + + it("should return null when app is not found", () => { + const mockMetadata = { + defaultConferencingApp: { + appSlug: "unknown-app", + }, + }; + + vi.mocked(getAppFromSlug).mockReturnValue(null); + + const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({ + organizerMetadata: mockMetadata, + }); + + expect(result).toBeNull(); + }); + }); + + describe("Static link apps", () => { + it("should return static link for apps with appLink", () => { + const mockMetadata = { + defaultConferencingApp: { + appSlug: "custom-video", + appLink: "https://custom-video.com/room123", + }, + }; + + vi.mocked(getAppFromSlug).mockReturnValue({ + appData: { + location: { + type: "integrations:custom-video", + }, + }, + } as any); + + const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({ + organizerMetadata: mockMetadata, + }); + + expect(result).toEqual({ + conferencingAppLocationType: "integrations:custom-video", + isStaticLinkApp: true, + staticLink: "https://custom-video.com/room123", + }); + }); + }); + + describe("Fallback scenarios", () => { + it("should return null when no conferencing app is set", () => { + const mockMetadata = {}; + + const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({ + organizerMetadata: mockMetadata, + }); + + expect(result).toBeNull(); + expect(getAppFromSlug).not.toHaveBeenCalled(); + }); + + it("should handle null metadata gracefully", () => { + const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({ + organizerMetadata: null, + }); + + expect(result).toBeNull(); + }); + + it("should handle undefined metadata gracefully", () => { + const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({ + organizerMetadata: undefined, + }); + + expect(result).toBeNull(); + }); + }); + + describe("Invalid metadata parsing", () => { + it("should handle invalid metadata structure", () => { + const invalidMetadata = { + defaultConferencingApp: "not-an-object", + }; + + const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({ + organizerMetadata: invalidMetadata, + }); + + expect(result).toBeNull(); + }); + + it("should handle metadata with missing appSlug", () => { + const mockMetadata = { + defaultConferencingApp: { + appLink: "https://some-link.com", + }, + }; + + const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({ + organizerMetadata: mockMetadata, + }); + + expect(result).toBeNull(); + }); + }); + + describe("Edge cases", () => { + it("should handle app with no appData", () => { + const mockMetadata = { + defaultConferencingApp: { + appSlug: "app-without-data", + }, + }; + + vi.mocked(getAppFromSlug).mockReturnValue({} as any); + + const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({ + organizerMetadata: mockMetadata, + }); + + expect(result).toBeNull(); + }); + + it("should handle app with no location type", () => { + const mockMetadata = { + defaultConferencingApp: { + appSlug: "app-without-location-type", + }, + }; + + vi.mocked(getAppFromSlug).mockReturnValue({ + appData: { + location: {}, + }, + } as any); + + const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({ + organizerMetadata: mockMetadata, + }); + + expect(result).toBeNull(); + }); + }); + }); + + describe("getLocationForHost", () => { + it("should use organizer default when allowed and available", () => { + const mockHostMetadata = { + defaultConferencingApp: { + appSlug: "zoom", + }, + }; + + vi.mocked(getAppFromSlug).mockReturnValue({ + appData: { + location: { + type: "integrations:zoom", + }, + }, + } as any); + + const result = BookingLocationService.getLocationForHost({ + hostMetadata: mockHostMetadata, + eventTypeLocations: [{ type: "conferencing" }], + isTeamEventType: true, + }); + + expect(result).toEqual({ + bookingLocation: "integrations:zoom", + requiresActualLink: true, + conferenceCredentialId: null, + }); + }); + + it("should use static link when organizer default is a static link app", () => { + const mockHostMetadata = { + defaultConferencingApp: { + appSlug: "custom-video", + appLink: "https://custom-video.com/room123", + }, + }; + + vi.mocked(getAppFromSlug).mockReturnValue({ + appData: { + location: { + type: "integrations:custom-video", + }, + }, + } as any); + + const result = BookingLocationService.getLocationForHost({ + hostMetadata: mockHostMetadata, + eventTypeLocations: [{ type: "conferencing" }], + isTeamEventType: true, + }); + + expect(result).toEqual({ + bookingLocation: "https://custom-video.com/room123", + requiresActualLink: false, + }); + }); + + it("should fallback to first real location when organizer default not available", () => { + const mockHostMetadata = {}; + + const result = BookingLocationService.getLocationForHost({ + hostMetadata: mockHostMetadata, + eventTypeLocations: [{ type: "conferencing" }, { type: "integrations:google:meet" }], + isTeamEventType: true, + }); + + expect(result).toEqual({ + bookingLocation: "integrations:google:meet", + requiresActualLink: true, + conferenceCredentialId: null, + }); + }); + + it("should default to Cal Video when no locations configured", () => { + const mockHostMetadata = {}; + + const result = BookingLocationService.getLocationForHost({ + hostMetadata: mockHostMetadata, + eventTypeLocations: [{ type: "conferencing" }], + isTeamEventType: true, + }); + + expect(result).toEqual({ + bookingLocation: "integrations:daily-video", + requiresActualLink: true, + conferenceCredentialId: null, + }); + }); + + it("should not use organizer default for non-team and non-managed events", () => { + const mockHostMetadata = { + defaultConferencingApp: { + appSlug: "zoom", + }, + }; + + vi.mocked(getAppFromSlug).mockReturnValue({ + appData: { + location: { + type: "integrations:zoom", + }, + }, + } as any); + + const result = BookingLocationService.getLocationForHost({ + hostMetadata: mockHostMetadata, + eventTypeLocations: [{ type: "conferencing" }, { type: "integrations:google:meet" }], + isTeamEventType: false, + isManagedEventType: false, + }); + + expect(result).toEqual({ + bookingLocation: "integrations:google:meet", + requiresActualLink: true, + conferenceCredentialId: null, + }); + }); + }); + + describe("getLocationDetailsFromType", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Dynamic location types", () => { + it("should return location type with credential ID when present", () => { + const locationType = "integrations:zoom"; + const eventTypeLocations = [ + { type: "integrations:zoom", credentialId: 123 }, + { type: "integrations:google:meet" }, + ]; + + const result = BookingLocationService.getLocationDetailsFromType({ + locationType, + eventTypeLocations, + }); + + expect(result).toEqual({ + bookingLocation: "integrations:zoom", + conferenceCredentialId: 123, + }); + }); + + it("should return location type without credential ID when not present", () => { + const locationType = "integrations:google:meet"; + const eventTypeLocations = [{ type: "integrations:google:meet" }, { type: "integrations:zoom" }]; + + const result = BookingLocationService.getLocationDetailsFromType({ + locationType, + eventTypeLocations, + }); + + expect(result).toEqual({ + bookingLocation: "integrations:google:meet", + conferenceCredentialId: null, + }); + }); + + it("should handle Cal Video location type", () => { + const locationType = "integrations:daily-video"; + const eventTypeLocations = [{ type: "integrations:daily-video" }]; + + const result = BookingLocationService.getLocationDetailsFromType({ + locationType, + eventTypeLocations, + }); + + expect(result).toEqual({ + bookingLocation: "integrations:daily-video", + conferenceCredentialId: null, + }); + }); + }); + + describe("Static link values", () => { + it("should return static URL as-is without credential ID", () => { + const locationType = "https://zoom.us/j/123456789"; + const eventTypeLocations = [{ type: "integrations:zoom", link: "https://zoom.us/j/123456789" }]; + + const result = BookingLocationService.getLocationDetailsFromType({ + locationType, + eventTypeLocations, + }); + + expect(result).toEqual({ + bookingLocation: "https://zoom.us/j/123456789", + conferenceCredentialId: null, + }); + }); + + it("should handle custom static links", () => { + const locationType = "https://custom-video.com/room123"; + const eventTypeLocations = [ + { type: "integrations:custom", link: "https://custom-video.com/room123" }, + ]; + + const result = BookingLocationService.getLocationDetailsFromType({ + locationType, + eventTypeLocations, + }); + + expect(result).toEqual({ + bookingLocation: "https://custom-video.com/room123", + conferenceCredentialId: null, + }); + }); + }); + + describe("Edge cases", () => { + it("should handle empty event type locations", () => { + const locationType = "integrations:zoom"; + const eventTypeLocations: any[] = []; + + const result = BookingLocationService.getLocationDetailsFromType({ + locationType, + eventTypeLocations, + }); + + expect(result).toEqual({ + bookingLocation: "integrations:zoom", + conferenceCredentialId: null, + }); + }); + + it("should handle location type not in event type locations", () => { + const locationType = "integrations:teams"; + const eventTypeLocations = [{ type: "integrations:zoom" }, { type: "integrations:google:meet" }]; + + const result = BookingLocationService.getLocationDetailsFromType({ + locationType, + eventTypeLocations, + }); + + expect(result).toEqual({ + bookingLocation: "integrations:teams", + conferenceCredentialId: null, + }); + }); + + it("should handle multiple locations with same type but different credential IDs", () => { + const locationType = "integrations:zoom"; + const eventTypeLocations = [ + { type: "integrations:zoom", credentialId: 123 }, + { type: "integrations:zoom", credentialId: 456 }, + ]; + + // getLocationValueForDB would pick the last matching one due to forEach + const result = BookingLocationService.getLocationDetailsFromType({ + locationType, + eventTypeLocations, + }); + + expect(result).toEqual({ + bookingLocation: "integrations:zoom", + conferenceCredentialId: 456, + }); + }); + + it("should handle location with 0 as credential ID", () => { + const locationType = "integrations:zoom"; + const eventTypeLocations = [{ type: "integrations:zoom", credentialId: 0 }]; + + const result = BookingLocationService.getLocationDetailsFromType({ + locationType, + eventTypeLocations, + }); + + expect(result).toEqual({ + bookingLocation: "integrations:zoom", + conferenceCredentialId: 0, + }); + }); + }); + + describe("Special location types", () => { + it("should handle phone location type", () => { + const locationType = "phone"; + const eventTypeLocations = [{ type: "phone" }]; + + const result = BookingLocationService.getLocationDetailsFromType({ + locationType, + eventTypeLocations, + }); + + expect(result).toEqual({ + bookingLocation: "phone", + conferenceCredentialId: null, + }); + }); + + it("should handle in-person location type", () => { + const locationType = "inPerson"; + const eventTypeLocations = [{ type: "inPerson", address: "123 Main St" }]; + + const result = BookingLocationService.getLocationDetailsFromType({ + locationType, + eventTypeLocations, + }); + + expect(result).toEqual({ + bookingLocation: "123 Main St", // getLocationValueForDB returns the address for inPerson + conferenceCredentialId: null, + }); + }); + + it("should handle attendee in-person location", () => { + const locationType = "attendeeInPerson"; + const eventTypeLocations = [{ type: "attendeeInPerson" }]; + + const result = BookingLocationService.getLocationDetailsFromType({ + locationType, + eventTypeLocations, + }); + + expect(result).toEqual({ + bookingLocation: "attendeeInPerson", + conferenceCredentialId: null, + }); + }); + }); + + describe("Conferencing app location", () => { + it("should handle conferencing location type (organizer default)", () => { + const locationType = "conferencing"; + const eventTypeLocations = [{ type: "conferencing" }]; + + const result = BookingLocationService.getLocationDetailsFromType({ + locationType, + eventTypeLocations, + }); + + expect(result).toEqual({ + bookingLocation: "conferencing", + conferenceCredentialId: null, + }); + }); + }); + }); +}); diff --git a/packages/lib/server/service/bookingLocationService.ts b/packages/lib/server/service/bookingLocationService.ts new file mode 100644 index 0000000000..14b2469d3b --- /dev/null +++ b/packages/lib/server/service/bookingLocationService.ts @@ -0,0 +1,247 @@ +import { getLocationValueForDB, OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations"; +import { CalVideoLocationType, type LocationObject } from "@calcom/app-store/locations"; +import { getAppFromSlug } from "@calcom/app-store/utils"; +import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; + +type GetOrganizerDefaultConferencingAppLocationParams = { + organizerMetadata: unknown; +}; + +type GetOrganizerDefaultConferencingAppLocationResult = { + /** + * The conferencing app location type. + * It starts with 'integrations:' e.g. 'integrations:zoom' and it is defined in the app's config/metadata.ts file + */ + conferencingAppLocationType: string; +} & ( + | { + isStaticLinkApp: true; + staticLink: string; + } + | { + isStaticLinkApp: false; + } +); + +type GetLocationForHostParams = { + /** + * The new host's metadata containing their default conferencing app preferences + */ + hostMetadata: unknown; + /** + * The event type's configured locations + */ + eventTypeLocations: LocationObject[]; + /** + * Whether this is a managed event type + */ + isManagedEventType?: boolean; + /** + * Whether this is a team event type + */ + isTeamEventType?: boolean; +}; + +type GetLocationForHostResult = { + /** + * The location string to be stored in the booking + */ + bookingLocation: string; +} & ( + | { + requiresActualLink: false; + } + | { + requiresActualLink: true; + // Credential ID for generating the conferencing link. Set when: + // 1. The app supports team installation (concurrentMeetings: true) + // 2. The app is installed at team level (credential has teamId) + // 3. The event type is a team event that can use team credentials + conferenceCredentialId: number | null; + } +); + +type GetLocationDetailsFromTypeParams = { + /** + * The location type or value to resolve + * @example "integrations:zoom" - A dynamic location type + * @example "https://example.com/meeting" - A static link value + * @example "integrations:daily" - Cal Video location type + */ + locationType: string; + /** + * The event type's configured locations that may contain credential IDs + */ + eventTypeLocations: LocationObject[]; +}; + +type GetLocationDetailsFromTypeResult = { + /** + * The resolved location value for the database + */ + bookingLocation: string; + /** + * The conference credential ID if present in the location configuration + */ + conferenceCredentialId: number | null; +}; + +export class BookingLocationService { + /** + * Determines the booking location based on the Organizer's Default Conferencing App. + * + * Note that Organizer's Default Conferencing App is applicable only to Team Events. It gets also applicable to Children Managed Event Types(which are technically User Event Types) as well because the location is configured at Parent Managed Event Type(which is a Team Event Type) + * + * This function handles two types of conferencing apps: + * 1. Dynamic link apps (e.g., Google Meet, Zoom) - Generate a new link for each booking + * - These apps only have `appSlug` in organizerMetadata + * - The location is determined by the app's metadata (e.g., "integrations:google:meet") + * + * 2. Static link apps (e.g., Campfire) + * - These apps have `appLink`, as well as `appSlug` in organizerMetadata + * - The `appLink` is only used for managed or team event types + * + * @returns The location configuration or null if no valid conferencing app is found. + * Callers should handle the null case with their own fallback logic. + */ + static getOrganizerDefaultConferencingAppLocation({ + organizerMetadata, + }: GetOrganizerDefaultConferencingAppLocationParams): GetOrganizerDefaultConferencingAppLocationResult | null { + // Parse the user metadata to extract conferencing app settings + const metadataParseResult = userMetadataSchema.safeParse(organizerMetadata); + const parsedMetadata = metadataParseResult.success ? metadataParseResult.data : undefined; + + if (!parsedMetadata?.defaultConferencingApp?.appSlug) { + return null; + } + + // Retrieve the app configuration from the app store + const app = getAppFromSlug(parsedMetadata.defaultConferencingApp.appSlug); + // Extract the location type from the app's metadata (e.g., "integrations:zoom") + const conferencingAppLocationType = app?.appData?.location?.type || null; + + // If the app doesn't have a valid location type, return null + if (!conferencingAppLocationType) { + return null; + } + + if (parsedMetadata.defaultConferencingApp.appLink) { + return { + conferencingAppLocationType, + isStaticLinkApp: true, + staticLink: parsedMetadata.defaultConferencingApp.appLink, + }; + } + + return { + conferencingAppLocationType, + isStaticLinkApp: false, + }; + } + + /** + * Determines the appropriate booking location based on the chosen host + * + * Logic: + * 1. If the event type includes OrganizerDefaultConferencingAppType in its locations, + * and the new host has a default conferencing app configured, use that. + * 2. Otherwise, use the first available location from the event type's configured locations. + * + * + * @returns The location configuration for the new host + */ + static getLocationForHost({ + hostMetadata, + eventTypeLocations, + isManagedEventType = false, + isTeamEventType = false, + }: GetLocationForHostParams): GetLocationForHostResult { + // Check if the event type allows using the organizer's default conferencing app + const eventAllowsOrganizerDefault = eventTypeLocations.some( + (location) => location.type === OrganizerDefaultConferencingAppType + ); + + const isOrganizerDefaultAllowed = isTeamEventType || isManagedEventType; + + if (eventAllowsOrganizerDefault && isOrganizerDefaultAllowed) { + const organizerDefaultLocation = BookingLocationService.getOrganizerDefaultConferencingAppLocation({ + organizerMetadata: hostMetadata, + }); + + if (organizerDefaultLocation) { + if (organizerDefaultLocation.isStaticLinkApp) { + return { + requiresActualLink: false, + bookingLocation: organizerDefaultLocation.staticLink, + }; + } + + return { + requiresActualLink: true, + conferenceCredentialId: null, + bookingLocation: organizerDefaultLocation.conferencingAppLocationType, + }; + } + } + + // Fallback: Find the first real location (not OrganizerDefaultConferencingAppType) + const firstRealLocation = eventTypeLocations.find( + (location) => location.type !== OrganizerDefaultConferencingAppType + ); + + if (!firstRealLocation) { + // If no real locations are configured, default to Cal Video + return { + bookingLocation: CalVideoLocationType, + requiresActualLink: true, + conferenceCredentialId: null, + }; + } + + // Process the first real location through getLocationValueForDB to handle static links properly + const { bookingLocation, conferenceCredentialId } = BookingLocationService.getLocationDetailsFromType({ + locationType: firstRealLocation.type, + eventTypeLocations, + }); + + return { + bookingLocation, + requiresActualLink: true, + conferenceCredentialId, + }; + } + + /** + * Extracts location details including conferenceCredentialId from a location type or value + * + * @param params - The location type/value and event type locations + * @returns The resolved booking location and conference credential ID if present + * + * @example + * // Dynamic location type with credential ID + * getLocationDetailsFromType({ + * locationType: "integrations:zoom", + * eventTypeLocations: [{ type: "integrations:zoom", credentialId: 123 }] + * }) + * // Returns: { bookingLocation: "integrations:zoom", conferenceCredentialId: 123 } + * + * @example + * // Static link value (already resolved) + * getLocationDetailsFromType({ + * locationType: "https://zoom.us/j/123456789", + * eventTypeLocations: [{ type: "integrations:zoom", link: "https://zoom.us/j/123456789" }] + * }) + * // Returns: { bookingLocation: "https://zoom.us/j/123456789", conferenceCredentialId: null } + */ + static getLocationDetailsFromType({ + locationType, + eventTypeLocations, + }: GetLocationDetailsFromTypeParams): GetLocationDetailsFromTypeResult { + const { bookingLocation, conferenceCredentialId } = getLocationValueForDB( + locationType, + eventTypeLocations + ); + + return { bookingLocation, conferenceCredentialId: conferenceCredentialId ?? null }; + } +}