From c31c5f861f6d686f8dba34993f1a0bd1f00330bd Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Tue, 16 Dec 2025 17:01:10 +0530 Subject: [PATCH 01/29] chore: save progress --- apps/web/public/static/locales/en/common.json | 6 + .../handleNewBooking/getEventTypesFromDB.ts | 11 + .../lib/service/RegularBookingService.ts | 55 +++ .../components/locations/HostLocations.tsx | 362 ++++++++++++++++++ .../components/tabs/setup/EventSetupTab.tsx | 7 + packages/features/eventtypes/lib/types.ts | 13 + .../repositories/eventTypeRepository.ts | 22 ++ packages/lib/server/eventTypeSelect.ts | 1 + .../event-types/hooks/useEventTypeForm.ts | 1 + .../migration.sql | 33 ++ packages/prisma/schema.prisma | 22 ++ .../routers/viewer/eventTypes/_router.ts | 12 + .../getHostsWithLocationOptions.handler.ts | 151 ++++++++ .../getHostsWithLocationOptions.schema.ts | 7 + .../viewer/eventTypes/heavy/update.handler.ts | 115 +++++- .../server/routers/viewer/eventTypes/types.ts | 13 + 16 files changed, 818 insertions(+), 13 deletions(-) create mode 100644 packages/features/eventtypes/components/locations/HostLocations.tsx create mode 100644 packages/prisma/migrations/20251216094314_add_host_custom_location/migration.sql create mode 100644 packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.schema.ts diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index ec8b3a76604889..fae653d7cbf18c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4162,5 +4162,11 @@ "timestamp": "Timestamp", "json": "JSON", "hubspot_ignore_guests": "Do not create new records for guests added to the booking", + "enable_custom_host_locations": "Enable custom host locations", + "enable_custom_host_locations_description": "Allow each round-robin host to have their own meeting location", + "host_locations": "Host Locations", + "apply_to_all_hosts": "Apply to all hosts", + "select_location": "Select location", + "host_locations_fallback_description": "When a host doesn't have the selected app installed, Cal Video will be used as a fallback", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index b94c02add21c13..299859af13585e 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -127,6 +127,7 @@ const getEventTypesFromDBSelect = { timeZone: true, }, }, + enablePerHostLocations: true, hosts: { select: { isFixed: true, @@ -134,6 +135,16 @@ const getEventTypesFromDBSelect = { weight: true, createdAt: true, groupId: true, + location: { + select: { + id: true, + type: true, + credentialId: true, + link: true, + address: true, + phoneNumber: true, + }, + }, user: { select: { credentials: { diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 5bc13cc2bb59a1..8f21634812d058 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -1277,6 +1277,61 @@ async function handler( const isManagedEventType = !!eventType.parentId; + // Handle per-host custom locations for round-robin events + if ( + eventType.enablePerHostLocations && + eventType.schedulingType === SchedulingType.ROUND_ROBIN && + organizerUser + ) { + const organizerHost = eventType.hosts.find((host) => host.user.id === organizerUser.id); + if (organizerHost?.location) { + const hostLocation = organizerHost.location; + // Check if the host has a valid credential for the location type + if (hostLocation.credentialId) { + // Use host's configured location with their credential + locationBodyString = hostLocation.type; + tracingLogger.info("Using per-host location", { + userId: organizerUser.id, + locationType: hostLocation.type, + credentialId: hostLocation.credentialId, + }); + } else if (hostLocation.type === "integrations:daily") { + // Cal Video doesn't need a credential + locationBodyString = hostLocation.type; + tracingLogger.info("Using per-host Cal Video location", { + userId: organizerUser.id, + }); + } else if (hostLocation.link) { + // Static link type + locationBodyString = hostLocation.type; + organizerOrFirstDynamicGroupMemberDefaultLocationUrl = hostLocation.link; + tracingLogger.info("Using per-host link location", { + userId: organizerUser.id, + link: hostLocation.link, + }); + } else if ( + hostLocation.type === "inPerson" || + hostLocation.type === "attendeeInPerson" || + hostLocation.type === "phone" || + hostLocation.type === "userPhone" + ) { + // Static location types that don't need credentials + locationBodyString = hostLocation.type; + tracingLogger.info("Using per-host static location", { + userId: organizerUser.id, + locationType: hostLocation.type, + }); + } else { + // Host has a conferencing app location but no credential installed yet - fallback to Cal Video + locationBodyString = "integrations:daily"; + tracingLogger.info("Host location configured but credential not found, falling back to Cal Video", { + userId: organizerUser.id, + requestedLocationType: hostLocation.type, + }); + } + } + } + // If location passed is empty , use default location of event // If location of event is not set , use host default if (locationBodyString.trim().length == 0) { diff --git a/packages/features/eventtypes/components/locations/HostLocations.tsx b/packages/features/eventtypes/components/locations/HostLocations.tsx new file mode 100644 index 00000000000000..c24001708f9fbe --- /dev/null +++ b/packages/features/eventtypes/components/locations/HostLocations.tsx @@ -0,0 +1,362 @@ +"use client"; + +import { useMemo } from "react"; +import { useFormContext } from "react-hook-form"; + +import type { LocationOption } from "@calcom/features/form/components/LocationSelect"; +import LocationSelect from "@calcom/features/form/components/LocationSelect"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Avatar } from "@calcom/ui/components/avatar"; +import { Badge } from "@calcom/ui/components/badge"; +import { Label } from "@calcom/ui/components/form"; +import { Select } from "@calcom/ui/components/form"; +import { SettingsToggle } from "@calcom/ui/components/form"; +import { Icon } from "@calcom/ui/components/icon"; +import { Skeleton } from "@calcom/ui/components/skeleton"; + +import type { FormValues, Host, HostLocation } from "../../lib/types"; +import type { TLocationOptions } from "./Locations"; + +type HostWithLocationOptions = { + userId: number; + name: string | null; + email: string; + avatarUrl: string | null; + location: { + id: number; + type: string; + credentialId: number | null; + link: string | null; + address: string | null; + phoneNumber: string | null; + } | null; + installedApps: { + appId: string | null; + credentialId: number; + type: string; + locationOption?: { + value: string; + label: string; + icon?: string; + }; + }[]; +}; + +type HostLocationsProps = { + eventTypeId: number; + locationOptions: TLocationOptions; +}; + +const getAppSlugFromLocationType = (locationType: string): string | null => { + if (locationType.startsWith("integrations:")) { + const parts = locationType.replace("integrations:", "").split(":"); + if (parts[0] === "daily") return "daily-video"; + if (parts[0] === "google") return "google-meet"; + if (parts[0] === "zoom") return "zoom"; + if (parts[0] === "teams") return "msteams"; + if (parts[0] === "huddle01") return "huddle01"; + if (parts[0] === "whereby") return "whereby"; + if (parts[0] === "around") return "around"; + if (parts[0] === "riverside") return "riverside"; + if (parts[0] === "webex") return "webex"; + return parts[0]; + } + return null; +}; + +const isStaticLocationType = (locationType: string): boolean => { + const staticTypes = ["inPerson", "link", "userPhone", "phone", "attendeeInPerson", "somewhereElse"]; + return staticTypes.includes(locationType); +}; + +const isCalVideo = (locationType: string): boolean => { + return locationType === "integrations:daily"; +}; + +const getLocationFromOptions = ( + locationType: string, + locationOptions: TLocationOptions +): LocationOption | undefined => { + for (const group of locationOptions) { + const option = group.options.find((opt) => opt.value === locationType); + if (option) return option; + } + return undefined; +}; + +const HostLocationRow = ({ + host, + hostData, + locationOptions, + onLocationChange, +}: { + host: Host; + hostData?: HostWithLocationOptions; + locationOptions: TLocationOptions; + onLocationChange: (userId: number, location: HostLocation | null) => void; +}) => { + const { t } = useLocale(); + + const currentLocation = host.location; + const selectedOption = currentLocation + ? getLocationFromOptions(currentLocation.type, locationOptions) + : null; + + const hasAppInstalled = useMemo(() => { + if (!currentLocation?.type) return true; + if (isStaticLocationType(currentLocation.type)) return true; + if (isCalVideo(currentLocation.type)) return true; + if (!hostData) return true; + + const appSlug = getAppSlugFromLocationType(currentLocation.type); + if (!appSlug) return true; + + return hostData.installedApps.some((app) => app.appId === appSlug || app.type === currentLocation.type); + }, [currentLocation, hostData]); + + const matchingCredential = useMemo(() => { + if (!currentLocation?.type || !hostData) return null; + if (isStaticLocationType(currentLocation.type)) return null; + if (isCalVideo(currentLocation.type)) return null; + + const appSlug = getAppSlugFromLocationType(currentLocation.type); + if (!appSlug) return null; + + return hostData.installedApps.find((app) => app.appId === appSlug || app.type === currentLocation.type); + }, [currentLocation, hostData]); + + const displayName = hostData?.name || `User ${host.userId}`; + const avatarUrl = hostData?.avatarUrl || ""; + + return ( +
+ +
+
{displayName}
+ {hostData?.email &&
{hostData.email}
} +
+
+ { + if (!option) { + onLocationChange(host.userId, null); + return; + } + const credential = hostData?.installedApps.find( + (app) => app.appId === getAppSlugFromLocationType(option.value) || app.type === option.value + ); + onLocationChange(host.userId, { + userId: host.userId, + eventTypeId: 0, + type: option.value, + credentialId: credential?.credentialId ?? null, + }); + }} + /> + {currentLocation && !hasAppInstalled && ( + + + {t("app_not_installed")} + + )} + {currentLocation && hasAppInstalled && matchingCredential && ( + + )} +
+
+ ); +}; + +const MassApplySelect = ({ + locationOptions, + onApply, +}: { + locationOptions: TLocationOptions; + onApply: (locationType: string) => void; +}) => { + const { t } = useLocale(); + + const options = useMemo(() => { + return [ + { value: "", label: t("apply_to_all_hosts") }, + ...locationOptions.flatMap((group) => + group.options.map((opt) => ({ + value: opt.value, + label: opt.label, + })) + ), + ]; + }, [locationOptions, t]); + + return ( + { const appMeta = cred.appId From 3cd5b635325a76e5ee126325ab72928b5cd6634b Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Tue, 13 Jan 2026 15:26:16 +0530 Subject: [PATCH 04/29] fix: type error --- packages/lib/test/builder.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index dec6bb3bc9bfc5..864dfefa6a1bc9 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -167,6 +167,7 @@ export const buildEventType = (eventType?: Partial): EventType => { createdAt: null, updatedAt: null, rrHostSubsetEnabled: false, + enablePerHostLocations: false, ...eventType, }; }; From 1442eed6f3ad48a5a759251bd65cfcb5b7b43ae6 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Tue, 13 Jan 2026 16:07:59 +0530 Subject: [PATCH 05/29] feat: mass apply dialog --- .../components/locations/HostLocations.tsx | 150 ++++++++++++++++-- apps/web/public/static/locales/en/common.json | 2 + packages/prisma/schema.prisma | 2 +- 3 files changed, 144 insertions(+), 10 deletions(-) diff --git a/apps/web/modules/event-types/components/locations/HostLocations.tsx b/apps/web/modules/event-types/components/locations/HostLocations.tsx index 380b0630c3aa15..4b8c992e3615ef 100644 --- a/apps/web/modules/event-types/components/locations/HostLocations.tsx +++ b/apps/web/modules/event-types/components/locations/HostLocations.tsx @@ -1,5 +1,6 @@ "use client"; +import { useSession } from "next-auth/react"; import { useEffect, useMemo, useState } from "react"; import { useFormContext } from "react-hook-form"; @@ -199,6 +200,80 @@ const HostLocationDialog = ({ ); }; +type MassApplyLocationDialogProps = { + isOpen: boolean; + onClose: () => void; + locationOption: LocationOption | null; + onSave: (inputValue: string) => void; +}; + +const MassApplyLocationDialog = ({ + isOpen, + onClose, + locationOption, + onSave, +}: MassApplyLocationDialogProps) => { + const { t } = useLocale(); + const eventLocationType = locationOption ? getEventLocationType(locationOption.value) : null; + const [inputValue, setInputValue] = useState(""); + + useEffect(() => { + if (isOpen) { + setInputValue(""); + } + }, [isOpen]); + + const handleSave = () => { + onSave(inputValue); + setInputValue(""); + onClose(); + }; + + const handleClose = () => { + setInputValue(""); + onClose(); + }; + + if (!eventLocationType) return null; + + return ( + !open && handleClose()}> + + +
+
+ + {eventLocationType.organizerInputType === "phone" ? ( + setInputValue(val || "")} + placeholder={t(eventLocationType.organizerInputPlaceholder || "")} + /> + ) : ( + setInputValue(e.target.value)} + placeholder={t(eventLocationType.organizerInputPlaceholder || "")} + type="text" + /> + )} +
+
+ + + + +
+
+ ); +}; + const HostLocationRow = ({ host, hostData, @@ -396,8 +471,14 @@ const MassApplySelect = ({ export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsProps) => { const { t } = useLocale(); + const session = useSession(); const formMethods = useFormContext(); + const [isMassApplyDialogOpen, setIsMassApplyDialogOpen] = useState(false); + const [pendingMassApplyOption, setPendingMassApplyOption] = useState(null); + + const isOrg = !!session.data?.user?.org?.id; + const enablePerHostLocations = formMethods.watch("enablePerHostLocations"); const hosts = formMethods.watch("hosts"); const roundRobinHosts = hosts.filter((h) => !h.isFixed); @@ -476,26 +557,63 @@ export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsPro formMethods.setValue("hosts", updatedHosts, { shouldDirty: true }); }; - const handleMassApply = (locationType: string) => { + const applyLocationToAllHosts = (locationType: string, inputValue: string | null) => { + const eventLocationType = getEventLocationType(locationType); + const updatedHosts = hosts.map((host) => { if (host.isFixed) return host; const hostData = hostDataMap.get(host.userId); const credential = hostData?.installedApps.find( (app) => app.appId === getAppSlugFromLocationType(locationType) || app.type === locationType ); - return { - ...host, - location: { - userId: host.userId, - eventTypeId: 0, - type: locationType, - credentialId: credential?.credentialId ?? null, - }, + + const location: HostLocation = { + userId: host.userId, + eventTypeId: 0, + type: locationType, + credentialId: credential?.credentialId ?? null, }; + + if (inputValue && eventLocationType) { + if (eventLocationType.defaultValueVariable === "link") { + location.link = inputValue; + } else if (eventLocationType.defaultValueVariable === "address") { + location.address = inputValue; + } else if (eventLocationType.organizerInputType === "phone") { + location.phoneNumber = inputValue; + } + } + + return { ...host, location }; }); formMethods.setValue("hosts", updatedHosts, { shouldDirty: true }); }; + const handleMassApply = (locationType: string) => { + const eventLocationType = getEventLocationType(locationType); + + if (eventLocationType?.organizerInputType) { + const option = getLocationFromOptions(locationType, mergedLocationOptions); + setPendingMassApplyOption(option || null); + setIsMassApplyDialogOpen(true); + return; + } + + applyLocationToAllHosts(locationType, null); + }; + + const handleMassApplyDialogSave = (inputValue: string) => { + if (pendingMassApplyOption) { + applyLocationToAllHosts(pendingMassApplyOption.value, inputValue); + } + setPendingMassApplyOption(null); + }; + + const handleMassApplyDialogClose = () => { + setIsMassApplyDialogOpen(false); + setPendingMassApplyOption(null); + }; + if (roundRobinHosts.length === 0) { return null; } @@ -508,6 +626,14 @@ export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsPro description={t("enable_custom_host_locations_description")} checked={enablePerHostLocations} onCheckedChange={handleToggle} + disabled={!isOrg} + Badge={ + !isOrg ? ( + + {t("upgrade")} + + ) : undefined + } /> {enablePerHostLocations && ( @@ -541,6 +667,12 @@ export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsPro )} + ); }; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4a2f3834ebf6a2..1e41f9ec2fe645 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1,4 +1,6 @@ { + "set_location_for_all_hosts": "Set location for all hosts", + "apply_to_all": "Apply to all", "identity_provider": "Identity provider", "trial_days_left": "You have $t(day, {\"count\": {{days}} }) left on your pro trial", "day_one": "{{count}} day", diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index eec392cf30a461..71aa96f3fc75f0 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -324,7 +324,7 @@ model Credential { delegationCredentialId String? delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: Cascade) integrationAttributeSyncs IntegrationAttributeSync[] - hostLocations HostLocation[] + hostLocations HostLocation[] @@index([appId]) @@index([subscriptionId]) From bef63cf40d680946aafae2d4941335bafb0ee0fd Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Tue, 13 Jan 2026 21:44:08 +0530 Subject: [PATCH 06/29] test: per host location --- .../components/locations/HostLocations.tsx | 34 +- .../test/per-host-locations.test.ts | 773 ++++++++++++++++++ .../lib/service/RegularBookingService.ts | 113 ++- .../repositories/HostLocationRepository.ts | 27 + .../lib/bookingScenario/bookingScenario.ts | 23 + .../getHostsWithLocationOptions.handler.ts | 7 +- 6 files changed, 913 insertions(+), 64 deletions(-) create mode 100644 packages/features/bookings/lib/handleNewBooking/test/per-host-locations.test.ts create mode 100644 packages/features/host/repositories/HostLocationRepository.ts diff --git a/apps/web/modules/event-types/components/locations/HostLocations.tsx b/apps/web/modules/event-types/components/locations/HostLocations.tsx index 4b8c992e3615ef..0472899fc9f315 100644 --- a/apps/web/modules/event-types/components/locations/HostLocations.tsx +++ b/apps/web/modules/event-types/components/locations/HostLocations.tsx @@ -318,17 +318,6 @@ const HostLocationRow = ({ return hostData.installedApps.some((app) => app.appId === appSlug || app.type === currentLocation.type); }, [currentLocation, hostData]); - const matchingCredential = useMemo(() => { - if (!currentLocation?.type || !hostData) return null; - if (isStaticLocationType(currentLocation.type)) return null; - if (isCalVideo(currentLocation.type)) return null; - - const appSlug = getAppSlugFromLocationType(currentLocation.type); - if (!appSlug) return null; - - return hostData.installedApps.find((app) => app.appId === appSlug || app.type === currentLocation.type); - }, [currentLocation, hostData]); - const displayName = hostData?.name || `User ${host.userId}`; const avatarUrl = hostData?.avatarUrl || ""; @@ -400,6 +389,12 @@ const HostLocationRow = ({ {hostData?.email &&
{hostData.email}
}
+ {currentLocation && !hasAppInstalled && ( + + + {t("app_not_installed")} + + )} {hasOrganizerInput && currentLocation && ( -
@@ -481,7 +467,6 @@ export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsPro const enablePerHostLocations = formMethods.watch("enablePerHostLocations"); const hosts = formMethods.watch("hosts"); - const roundRobinHosts = hosts.filter((h) => !h.isFixed); const { data: hostsWithApps, isLoading } = trpc.viewer.eventTypes.getHostsWithLocationOptions.useQuery( { eventTypeId }, @@ -561,7 +546,6 @@ export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsPro const eventLocationType = getEventLocationType(locationType); const updatedHosts = hosts.map((host) => { - if (host.isFixed) return host; const hostData = hostDataMap.get(host.userId); const credential = hostData?.installedApps.find( (app) => app.appId === getAppSlugFromLocationType(locationType) || app.type === locationType @@ -614,7 +598,7 @@ export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsPro setPendingMassApplyOption(null); }; - if (roundRobinHosts.length === 0) { + if (hosts.length === 0) { return null; } @@ -651,7 +635,7 @@ export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsPro ) : ( - roundRobinHosts.map((host) => ( + hosts.map((host) => ( { + setupAndTeardown(); + + describe("Round-Robin with enablePerHostLocations enabled", () => { + test("should use host's Zoom credential when host has credentialId", async () => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential(), getZoomAppCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + schedulingType: SchedulingType.ROUND_ROBIN, + length: 30, + enablePerHostLocations: true, + users: [{ id: organizer.id }], + hosts: [ + { + userId: organizer.id, + isFixed: false, + location: { + type: "integrations:zoom", + credentialId: 2, + }, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"], TestData.apps["zoomvideo"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "zoomvideo", + videoMeetingData: { + id: "MOCK_ZOOM_ID", + password: "MOCK_ZOOM_PASS", + url: `https://zoom.us/j/123456789`, + }, + }); + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${getDate({ dateIncrement: 1 }).dateString}T09:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T09:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(createdBooking).toBeDefined(); + expect(createdBooking.location).toBe("integrations:zoom"); + + await expectBookingToBeInDatabase({ + uid: createdBooking.uid, + location: "integrations:zoom", + }); + }); + + test("should use Cal Video when host location is integrations:daily", async () => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + schedulingType: SchedulingType.ROUND_ROBIN, + length: 30, + enablePerHostLocations: true, + users: [{ id: organizer.id }], + hosts: [ + { + userId: organizer.id, + isFixed: false, + location: { + type: "integrations:daily", + }, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_DAILY_ID", + password: "MOCK_DAILY_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}T09:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T09:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(createdBooking).toBeDefined(); + expect(createdBooking.location).toBe("integrations:daily"); + + await expectBookingToBeInDatabase({ + uid: createdBooking.uid, + location: "integrations:daily", + }); + }); + + test("should use stored link when host location type is link", async () => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + const customLink = "https://custom-meeting.example.com/room123"; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + schedulingType: SchedulingType.ROUND_ROBIN, + length: 30, + enablePerHostLocations: true, + users: [{ id: organizer.id }], + hosts: [ + { + userId: organizer.id, + isFixed: false, + location: { + type: "link", + link: customLink, + }, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${getDate({ dateIncrement: 1 }).dateString}T09:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T09:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "link" }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(createdBooking).toBeDefined(); + expect(createdBooking.location).toBe(customLink); + + await expectBookingToBeInDatabase({ + uid: createdBooking.uid, + location: customLink, + }); + }); + + test("should use stored address when host location type is inPerson", async () => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + const officeAddress = "123 Main St, San Francisco, CA 94102"; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + schedulingType: SchedulingType.ROUND_ROBIN, + length: 30, + enablePerHostLocations: true, + users: [{ id: organizer.id }], + hosts: [ + { + userId: organizer.id, + isFixed: false, + location: { + type: "inPerson", + address: officeAddress, + }, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${getDate({ dateIncrement: 1 }).dateString}T09:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T09:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "inPerson" }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(createdBooking).toBeDefined(); + expect(createdBooking.location).toBe(officeAddress); + + await expectBookingToBeInDatabase({ + uid: createdBooking.uid, + location: officeAddress, + }); + }); + + test("should auto-link credential when host has location type but no credential", async () => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential(), getZoomAppCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + schedulingType: SchedulingType.ROUND_ROBIN, + length: 30, + enablePerHostLocations: true, + users: [{ id: organizer.id }], + hosts: [ + { + userId: organizer.id, + isFixed: false, + location: { + type: "integrations:zoom", + credentialId: null, + }, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"], TestData.apps["zoomvideo"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "zoomvideo", + videoMeetingData: { + id: "MOCK_ZOOM_ID", + password: "MOCK_ZOOM_PASS", + url: `https://zoom.us/j/123456789`, + }, + }); + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${getDate({ dateIncrement: 1 }).dateString}T09:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T09:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(createdBooking).toBeDefined(); + expect(createdBooking.location).toBe("integrations:zoom"); + + await expectBookingToBeInDatabase({ + uid: createdBooking.uid, + location: "integrations:zoom", + }); + + const hostLocation = await prisma.hostLocation.findUnique({ + where: { + userId_eventTypeId: { + userId: organizer.id, + eventTypeId: 1, + }, + }, + }); + expect(hostLocation?.credentialId).not.toBeNull(); + }); + + test("should fallback to Cal Video when no matching credential found", async () => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + schedulingType: SchedulingType.ROUND_ROBIN, + length: 30, + enablePerHostLocations: true, + users: [{ id: organizer.id }], + hosts: [ + { + userId: organizer.id, + isFixed: false, + location: { + type: "integrations:zoom", + credentialId: null, + }, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_DAILY_ID", + password: "MOCK_DAILY_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}T09:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T09:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(createdBooking).toBeDefined(); + expect(createdBooking.location).toBe("integrations:daily"); + + await expectBookingToBeInDatabase({ + uid: createdBooking.uid, + location: "integrations:daily", + }); + }); + }); + + describe("Feature flag behavior", () => { + test("should ignore per-host locations when enablePerHostLocations is false", async () => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential(), getZoomAppCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + schedulingType: SchedulingType.ROUND_ROBIN, + length: 30, + enablePerHostLocations: false, + users: [{ id: organizer.id }], + hosts: [ + { + userId: organizer.id, + isFixed: false, + location: { + type: "integrations:zoom", + credentialId: 2, + }, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"], TestData.apps["zoomvideo"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_DAILY_ID", + password: "MOCK_DAILY_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}T09:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T09:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(createdBooking).toBeDefined(); + expect(createdBooking.location).toBe("integrations:daily"); + + await expectBookingToBeInDatabase({ + uid: createdBooking.uid, + location: "integrations:daily", + }); + }); + + test("should ignore per-host locations for non-round-robin events", async () => { + const handleNewBooking = getNewBookingHandler(); + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential(), getZoomAppCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + schedulingType: SchedulingType.COLLECTIVE, + length: 30, + enablePerHostLocations: true, + users: [{ id: organizer.id }], + hosts: [ + { + userId: organizer.id, + isFixed: true, + location: { + type: "integrations:zoom", + credentialId: 2, + }, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"], TestData.apps["zoomvideo"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_DAILY_ID", + password: "MOCK_DAILY_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}T09:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T09:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(createdBooking).toBeDefined(); + expect(createdBooking.location).toBe("integrations:daily"); + + await expectBookingToBeInDatabase({ + uid: createdBooking.uid, + location: "integrations:daily", + }); + }); + }); +}); diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 8fa1fee4020a9c..e4919b27d26f19 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -13,7 +13,9 @@ import { MeetLocationType, OrganizerDefaultConferencingAppType, } from "@calcom/app-store/locations"; +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { getAppFromSlug } from "@calcom/app-store/utils"; +import { HostLocationRepository } from "@calcom/features/host/repositories/HostLocationRepository"; import { eventTypeMetaDataSchemaWithTypedApps, eventTypeAppMetadataOptionalSchema, @@ -605,9 +607,9 @@ async function handler( userId: userId ?? null, eventType: eventType ? { - seatsPerTimeSlot: eventType.seatsPerTimeSlot, - minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null, - } + seatsPerTimeSlot: eventType.seatsPerTimeSlot, + minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null, + } : null, }); @@ -1259,7 +1261,7 @@ async function handler( // If the team member is requested then they should be the organizer const organizerUser = reqBody.teamMemberEmail - ? users.find((user) => user.email === reqBody.teamMemberEmail) ?? users[0] + ? (users.find((user) => user.email === reqBody.teamMemberEmail) ?? users[0]) : users[0]; const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common"); @@ -1278,6 +1280,9 @@ async function handler( const isManagedEventType = !!eventType.parentId; + // Track credential ID for per-host locations + let perHostCredentialId: number | undefined = undefined; + // Handle per-host custom locations for round-robin events if ( eventType.enablePerHostLocations && @@ -1291,6 +1296,7 @@ async function handler( if (hostLocation.credentialId) { // Use host's configured location with their credential locationBodyString = hostLocation.type; + perHostCredentialId = hostLocation.credentialId; tracingLogger.info("Using per-host location", { userId: organizerUser.id, locationType: hostLocation.type, @@ -1329,12 +1335,45 @@ async function handler( locationType: hostLocation.type, }); } else { - // Host has a conferencing app location but no credential installed yet - fallback to Cal Video - locationBodyString = "integrations:daily"; - tracingLogger.info("Host location configured but credential not found, falling back to Cal Video", { - userId: organizerUser.id, - requestedLocationType: hostLocation.type, - }); + // Host has a conferencing app location but no credential - try to find one from allCredentials + const appMetaForLocation = Object.values(appStoreMetadata).find( + (app) => app.appData?.location?.type === hostLocation.type + ); + + if (appMetaForLocation) { + const matchingCredential = allCredentials.find((cred) => cred.type === appMetaForLocation.type); + + if (matchingCredential) { + locationBodyString = hostLocation.type; + perHostCredentialId = matchingCredential.id; + + // Link the credential to the HostLocation for future bookings + const hostLocationRepository = new HostLocationRepository(deps.prismaClient); + await hostLocationRepository.linkCredential({ + userId: organizerUser.id, + eventTypeId: eventType.id, + credentialId: matchingCredential.id, + }); + + tracingLogger.info("Found and linked credential for per-host location", { + userId: organizerUser.id, + locationType: hostLocation.type, + credentialId: matchingCredential.id, + }); + } else { + locationBodyString = "integrations:daily"; + tracingLogger.info("No credential found for per-host location, falling back to Cal Video", { + userId: organizerUser.id, + requestedLocationType: hostLocation.type, + }); + } + } else { + locationBodyString = "integrations:daily"; + tracingLogger.info("Unknown location type, falling back to Cal Video", { + userId: organizerUser.id, + requestedLocationType: hostLocation.type, + }); + } } } } @@ -1436,12 +1475,16 @@ async function handler( // For static link based video apps, it would have the static URL value instead of it's type(e.g. integrations:campfire_video) // This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them. - const { bookingLocation, conferenceCredentialId } = organizerOrFirstDynamicGroupMemberDefaultLocationUrl - ? { - bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl, - conferenceCredentialId: undefined, - } - : getLocationValueForDB(locationBodyString, eventType.locations); + const { bookingLocation, conferenceCredentialId: eventTypeCredentialId } = + organizerOrFirstDynamicGroupMemberDefaultLocationUrl + ? { + bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl, + conferenceCredentialId: undefined, + } + : getLocationValueForDB(locationBodyString, eventType.locations); + + // Use per-host credential if available, otherwise fall back to event type credential + const conferenceCredentialId = perHostCredentialId ?? eventTypeCredentialId; tracingLogger.info("locationBodyString", locationBodyString); tracingLogger.info("event type locations", eventType.locations); @@ -1563,7 +1606,7 @@ async function handler( platformBookingUrl, }) .withOrganization(organizerOrganizationId) - .withHashedLink(hasHashedBookingLink ? reqBody.hashedLink ?? null : null) + .withHashedLink(hasHashedBookingLink ? (reqBody.hashedLink ?? null) : null) .build(); if (!builtEvt) { @@ -2109,14 +2152,14 @@ async function handler( } const updateManager = !skipCalendarSyncTaskCreation ? await eventManager.reschedule( - evt, - originalRescheduledBooking.uid, - undefined, - changedOrganizer, - previousHostDestinationCalendar, - isBookingRequestedReschedule, - skipDeleteEventsAndMeetings - ) + evt, + originalRescheduledBooking.uid, + undefined, + changedOrganizer, + previousHostDestinationCalendar, + isBookingRequestedReschedule, + skipDeleteEventsAndMeetings + ) : placeholderCreatedEvent; // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back // to the default description when we are sending the emails. @@ -2180,7 +2223,7 @@ async function handler( const googleHangoutLink = Array.isArray(googleCalResult?.updatedEvent) ? googleCalResult.updatedEvent[0]?.hangoutLink - : googleCalResult?.updatedEvent?.hangoutLink ?? googleCalResult?.createdEvent?.hangoutLink; + : (googleCalResult?.updatedEvent?.hangoutLink ?? googleCalResult?.createdEvent?.hangoutLink); if (googleHangoutLink) { results.push({ @@ -2210,7 +2253,7 @@ async function handler( } const createdOrUpdatedEvent = Array.isArray(results[0]?.updatedEvent) ? results[0]?.updatedEvent[0] - : results[0]?.updatedEvent ?? results[0]?.createdEvent; + : (results[0]?.updatedEvent ?? results[0]?.createdEvent); metadata.hangoutLink = createdOrUpdatedEvent?.hangoutLink; metadata.conferenceData = createdOrUpdatedEvent?.conferenceData; metadata.entryPoints = createdOrUpdatedEvent?.entryPoints; @@ -2417,22 +2460,22 @@ async function handler( const metadata = videoCallUrl ? { - videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, - } + videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, + } : undefined; const bookingCreatedPayload = buildBookingCreatedPayload({ booking, organizerUserId: organizerUser.id, // FIXME: It looks like hasHashedBookingLink is set to true based on the value of hashedLink when sending the request. So, technically we could remove hasHashedBookingLink usage completely - hashedLink: hasHashedBookingLink ? reqBody.hashedLink ?? null : null, + hashedLink: hasHashedBookingLink ? (reqBody.hashedLink ?? null) : null, isDryRun, organizationId: eventOrganizationId, }); const bookingEventHandler = deps.bookingEventHandler; // TODO: Identify action source correctly - const actionSource = 'WEBAPP'; + const actionSource = "WEBAPP"; // TODO: We need to check session in booking flow and accordingly create USER actor if applicable. const auditActor = makeGuestActor({ email: bookerEmail, name: fullName }); @@ -2541,9 +2584,9 @@ async function handler( ...eventType, metadata: eventType.metadata ? { - ...eventType.metadata, - apps: eventType.metadata?.apps as Prisma.JsonValue, - } + ...eventType.metadata, + apps: eventType.metadata?.apps as Prisma.JsonValue, + } : {}, }, paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType, @@ -2881,7 +2924,7 @@ async function handler( * We are open to renaming it to something more descriptive. */ export class RegularBookingService implements IBookingService { - constructor(private readonly deps: IBookingServiceDependencies) { } + constructor(private readonly deps: IBookingServiceDependencies) {} async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) { return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps); diff --git a/packages/features/host/repositories/HostLocationRepository.ts b/packages/features/host/repositories/HostLocationRepository.ts new file mode 100644 index 00000000000000..3159ab87a0ad16 --- /dev/null +++ b/packages/features/host/repositories/HostLocationRepository.ts @@ -0,0 +1,27 @@ +import type { PrismaClient } from "@calcom/prisma"; + +export class HostLocationRepository { + constructor(private prismaClient: PrismaClient) {} + + async linkCredential({ + userId, + eventTypeId, + credentialId, + }: { + userId: number; + eventTypeId: number; + credentialId: number; + }) { + return await this.prismaClient.hostLocation.update({ + where: { + userId_eventTypeId: { + userId, + eventTypeId, + }, + }, + data: { + credentialId, + }, + }); + } +} diff --git a/packages/testing/src/lib/bookingScenario/bookingScenario.ts b/packages/testing/src/lib/bookingScenario/bookingScenario.ts index 6365fff90ce67b..b098cfa8cbf00b 100644 --- a/packages/testing/src/lib/bookingScenario/bookingScenario.ts +++ b/packages/testing/src/lib/bookingScenario/bookingScenario.ts @@ -140,11 +140,20 @@ type InputWorkflowReminder = { workflowId: number; }; +type InputHostLocation = { + type: string; + credentialId?: number | null; + link?: string | null; + address?: string | null; + phoneNumber?: string | null; +}; + type InputHost = { userId: number; isFixed?: boolean; scheduleId?: number | null; groupId?: string | null; + location?: InputHostLocation | null; }; type InputSelectedSlot = { @@ -362,6 +371,20 @@ async function addHostsToDb(eventTypes: InputEventType[]) { await prismock.host.create({ data, }); + + if (host.location) { + await prismock.hostLocation.create({ + data: { + userId: host.userId, + eventTypeId: eventType.id, + type: host.location.type, + credentialId: host.location.credentialId ?? null, + link: host.location.link ?? null, + address: host.location.address ?? null, + phoneNumber: host.location.phoneNumber ?? null, + }, + }); + } } } } diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts index f24cbb0faa3d66..6b5e51301d9f32 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts @@ -75,7 +75,6 @@ export const getHostsWithLocationOptionsHandler = async ({ const hosts = await ctx.prisma.host.findMany({ where: { eventTypeId, - isFixed: false, }, select: { userId: true, @@ -125,6 +124,8 @@ export const getHostsWithLocationOptionsHandler = async ({ users: usersForEnrichment, }); + const appMetadataBySlug = new Map(Object.values(appStoreMetadata).map((app) => [app.slug, app])); + return hosts.map((host, index) => ({ userId: host.userId, name: host.user.name, @@ -133,9 +134,7 @@ export const getHostsWithLocationOptionsHandler = async ({ defaultConferencingApp: userMetadata.parse(host.user.metadata)?.defaultConferencingApp ?? null, location: host.location, installedApps: enrichedUsers[index].credentials.map((cred) => { - const appMeta = cred.appId - ? Object.values(appStoreMetadata).find((app) => app.slug === cred.appId) - : null; + const appMeta = cred.appId ? appMetadataBySlug.get(cred.appId) : null; const locationData = appMeta?.appData?.location; return { From d017066417a663e0b780345102287121a69a97c7 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Tue, 13 Jan 2026 21:44:19 +0530 Subject: [PATCH 07/29] test: fix test --- apps/web/test/lib/handleChildrenEventTypes.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index 8b4200380b61a2..90bb7a7ae79491 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -139,6 +139,7 @@ describe("handleChildrenEventTypes", () => { autoTranslateInstantMeetingTitleEnabled, includeNoShowInRRCalculation, instantMeetingScheduleId, + enablePerHostLocations, ...evType } = mockFindFirstEventType({ id: 123, @@ -228,6 +229,7 @@ describe("handleChildrenEventTypes", () => { autoTranslateInstantMeetingTitleEnabled, includeNoShowInRRCalculation, instantMeetingScheduleId, + enablePerHostLocations, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, @@ -340,6 +342,7 @@ describe("handleChildrenEventTypes", () => { includeNoShowInRRCalculation, instantMeetingScheduleId, assignRRMembersUsingSegment, + enablePerHostLocations, ...evType } = mockFindFirstEventType({ id: 123, @@ -432,6 +435,7 @@ describe("handleChildrenEventTypes", () => { assignRRMembersUsingSegment, rrSegmentQueryValue, useEventLevelSelectedCalendars, + enablePerHostLocations, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, @@ -498,6 +502,7 @@ describe("handleChildrenEventTypes", () => { includeNoShowInRRCalculation, instantMeetingScheduleId, assignRRMembersUsingSegment, + enablePerHostLocations, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, From 1aa3ff1cff5e537990505661c2715828362b208f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:37:31 +0000 Subject: [PATCH 08/29] fix: address Cubic AI review feedback (confidence >= 9/10) - Remove PII (address, phone number) from tracing logs in RegularBookingService.ts - Constrain HostLocation.type to EventLocationType["type"] for compile-time validation Co-Authored-By: unknown <> --- packages/features/bookings/lib/service/RegularBookingService.ts | 2 -- packages/features/eventtypes/lib/types.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index cc8323cc8d61ab..ead9e7b2949687 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -1341,13 +1341,11 @@ async function handler( locationBodyString = hostLocation.address || hostLocation.type; tracingLogger.info("Using per-host in-person location", { userId: organizerUser.id, - address: hostLocation.address, }); } else if (hostLocation.type === "userPhone") { locationBodyString = hostLocation.phoneNumber || hostLocation.type; tracingLogger.info("Using per-host organizer phone location", { userId: organizerUser.id, - phoneNumber: hostLocation.phoneNumber, }); } else if (hostLocation.type === "attendeeInPerson" || hostLocation.type === "phone") { locationBodyString = hostLocation.type; diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 03d2b9d676a4ac..1816a5ec58ed22 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -31,7 +31,7 @@ export type HostLocation = { id?: number; userId: number; eventTypeId: number; - type: string; + type: EventLocationType["type"]; credentialId?: number | null; link?: string | null; address?: string | null; From 6c79da096d45c70dd3bffa8ee776e74dee56a5fe Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Tue, 13 Jan 2026 22:39:02 +0530 Subject: [PATCH 09/29] fix: translation --- .../modules/event-types/components/locations/HostLocations.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/modules/event-types/components/locations/HostLocations.tsx b/apps/web/modules/event-types/components/locations/HostLocations.tsx index 0472899fc9f315..d52fb36c903ab3 100644 --- a/apps/web/modules/event-types/components/locations/HostLocations.tsx +++ b/apps/web/modules/event-types/components/locations/HostLocations.tsx @@ -318,7 +318,7 @@ const HostLocationRow = ({ return hostData.installedApps.some((app) => app.appId === appSlug || app.type === currentLocation.type); }, [currentLocation, hostData]); - const displayName = hostData?.name || `User ${host.userId}`; + const displayName = hostData?.name || `${t("user")} ${host.userId}`; const avatarUrl = hostData?.avatarUrl || ""; const currentLocationEventType = currentLocation ? getEventLocationType(currentLocation.type) : null; From 64a8a5b27960954717d7c4bf923281577eea7405 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Wed, 14 Jan 2026 14:19:11 +0530 Subject: [PATCH 10/29] refactor: improvements --- .../components/event-meta/Details.tsx | 3 +- .../components/locations/HostLocations.tsx | 198 +++++------------- .../components/tabs/setup/EventSetupTab.tsx | 89 ++++---- apps/web/public/static/locales/en/common.json | 7 +- .../app-store/calendar.services.generated.ts | 31 +-- packages/app-store/locations.ts | 16 ++ .../lib/service/RegularBookingService.ts | 97 ++------- packages/features/bookings/types.ts | 1 + .../round-robin/lib/bookingLocationService.ts | 121 +++++++++++ .../features/eventtypes/lib/getPublicEvent.ts | 1 + .../host/repositories/HostRepository.ts | 45 ++++ .../getHostsWithLocationOptions.handler.ts | 45 +--- 12 files changed, 331 insertions(+), 323 deletions(-) diff --git a/apps/web/modules/bookings/components/event-meta/Details.tsx b/apps/web/modules/bookings/components/event-meta/Details.tsx index 693d2962562c06..03fdbea0db1f66 100644 --- a/apps/web/modules/bookings/components/event-meta/Details.tsx +++ b/apps/web/modules/bookings/components/event-meta/Details.tsx @@ -21,6 +21,7 @@ type EventDetailsPropsBase = { | "currency" | "price" | "locations" + | "enablePerHostLocations" | "requiresConfirmation" | "recurringEvent" | "length" @@ -149,7 +150,7 @@ export const EventDetails = ({ event, blocks = defaultEventDetailsBlocks }: Even ); case EventDetailBlocks.LOCATION: - if (!event?.locations?.length || isInstantMeeting) return null; + if (!event?.locations?.length || isInstantMeeting || event.enablePerHostLocations) return null; return ( diff --git a/apps/web/modules/event-types/components/locations/HostLocations.tsx b/apps/web/modules/event-types/components/locations/HostLocations.tsx index d52fb36c903ab3..92f2acc77e20ce 100644 --- a/apps/web/modules/event-types/components/locations/HostLocations.tsx +++ b/apps/web/modules/event-types/components/locations/HostLocations.tsx @@ -4,7 +4,12 @@ import { useSession } from "next-auth/react"; import { useEffect, useMemo, useState } from "react"; import { useFormContext } from "react-hook-form"; -import { getEventLocationType } from "@calcom/app-store/locations"; +import { + getAppSlugFromLocationType, + getEventLocationType, + isCalVideoLocation, + isStaticLocationType, +} from "@calcom/app-store/locations"; import { getAppFromSlug } from "@calcom/app-store/utils"; import PhoneInput from "@calcom/features/components/phone-input"; import type { LocationOption } from "@calcom/features/form/components/LocationSelect"; @@ -58,32 +63,6 @@ type HostLocationsProps = { locationOptions: TLocationOptions; }; -const getAppSlugFromLocationType = (locationType: string): string | null => { - if (locationType.startsWith("integrations:")) { - const parts = locationType.replace("integrations:", "").split(":"); - if (parts[0] === "daily") return "daily-video"; - if (parts[0] === "google") return "google-meet"; - if (parts[0] === "zoom") return "zoom"; - if (parts[0] === "teams") return "msteams"; - if (parts[0] === "huddle01") return "huddle01"; - if (parts[0] === "whereby") return "whereby"; - if (parts[0] === "around") return "around"; - if (parts[0] === "riverside") return "riverside"; - if (parts[0] === "webex") return "webex"; - return parts[0]; - } - return null; -}; - -const isStaticLocationType = (locationType: string): boolean => { - const staticTypes = ["inPerson", "link", "userPhone", "phone", "attendeeInPerson", "somewhereElse"]; - return staticTypes.includes(locationType); -}; - -const isCalVideo = (locationType: string): boolean => { - return locationType === "integrations:daily"; -}; - const getLocationFromOptions = ( locationType: string, locationOptions: TLocationOptions @@ -95,134 +74,35 @@ const getLocationFromOptions = ( return undefined; }; -type HostLocationDialogProps = { +type LocationInputDialogProps = { isOpen: boolean; onClose: () => void; locationOption: LocationOption | null; - onSave: (location: HostLocation) => void; - hostUserId: number; - hostData?: HostWithLocationOptions; + onSave: (inputValue: string) => void; + title: string; + saveButtonText: string; initialValue?: string; }; -const HostLocationDialog = ({ +const LocationInputDialog = ({ isOpen, onClose, locationOption, onSave, - hostUserId, - hostData, - initialValue, -}: HostLocationDialogProps) => { + title, + saveButtonText, + initialValue = "", +}: LocationInputDialogProps) => { const { t } = useLocale(); const eventLocationType = locationOption ? getEventLocationType(locationOption.value) : null; - - const [inputValue, setInputValue] = useState(initialValue || ""); + const [inputValue, setInputValue] = useState(initialValue); useEffect(() => { if (isOpen) { - setInputValue(initialValue || ""); + setInputValue(initialValue); } }, [isOpen, initialValue]); - const handleSave = () => { - if (!locationOption || !eventLocationType) return; - - const credential = hostData?.installedApps.find( - (app) => - app.appId === getAppSlugFromLocationType(locationOption.value) || app.type === locationOption.value - ); - - const location: HostLocation = { - userId: hostUserId, - eventTypeId: 0, - type: locationOption.value, - credentialId: credential?.credentialId ?? null, - }; - - if (eventLocationType.organizerInputType === "text") { - if (eventLocationType.defaultValueVariable === "link") { - location.link = inputValue; - } else if (eventLocationType.defaultValueVariable === "address") { - location.address = inputValue; - } - } else if (eventLocationType.organizerInputType === "phone") { - location.phoneNumber = inputValue; - } - - onSave(location); - setInputValue(""); - onClose(); - }; - - const handleClose = () => { - setInputValue(""); - onClose(); - }; - - if (!eventLocationType) return null; - - return ( - !open && handleClose()}> - - -
-
- - {eventLocationType.organizerInputType === "phone" ? ( - setInputValue(val || "")} - placeholder={t(eventLocationType.organizerInputPlaceholder || "")} - /> - ) : ( - setInputValue(e.target.value)} - placeholder={t(eventLocationType.organizerInputPlaceholder || "")} - type="text" - /> - )} -
-
- - - - -
-
- ); -}; - -type MassApplyLocationDialogProps = { - isOpen: boolean; - onClose: () => void; - locationOption: LocationOption | null; - onSave: (inputValue: string) => void; -}; - -const MassApplyLocationDialog = ({ - isOpen, - onClose, - locationOption, - onSave, -}: MassApplyLocationDialogProps) => { - const { t } = useLocale(); - const eventLocationType = locationOption ? getEventLocationType(locationOption.value) : null; - const [inputValue, setInputValue] = useState(""); - - useEffect(() => { - if (isOpen) { - setInputValue(""); - } - }, [isOpen]); - const handleSave = () => { onSave(inputValue); setInputValue(""); @@ -239,7 +119,7 @@ const MassApplyLocationDialog = ({ return ( !open && handleClose()}> - +
- @@ -651,11 +557,13 @@ export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsPro )} - ); diff --git a/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx b/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx index bef685f3632a8b..d2bf409fc43701 100644 --- a/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx +++ b/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx @@ -27,6 +27,7 @@ import { TextField } from "@calcom/ui/components/form"; import { Select } from "@calcom/ui/components/form"; import { SettingsToggle } from "@calcom/ui/components/form"; import { Skeleton } from "@calcom/ui/components/skeleton"; +import { Tooltip } from "@calcom/ui/components/tooltip"; import HostLocations from "@calcom/web/modules/event-types/components/locations/HostLocations"; import Locations from "@calcom/web/modules/event-types/components/locations/Locations"; @@ -80,6 +81,7 @@ export const EventSetupTab = ( const [firstRender, setFirstRender] = useState(true); const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled"); + const enablePerHostLocations = formMethods.watch("enablePerHostLocations"); const multipleDurationOptions = [ 5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480, @@ -341,43 +343,58 @@ export const EventSetupTab = ( )} -
-
- - {t("location")} - {/*improve shouldLockIndicator function to also accept eventType and then conditionally render - based on Managed Event type or not.*/} - {shouldLockIndicator("locations")} - - ( - } - setValue={formMethods.setValue as unknown as UseFormSetValue} - control={formMethods.control as unknown as Control} - formState={formMethods.formState as unknown as FormState} - {...props} - customClassNames={customClassNames?.locationSection} - /> - )} - /> + +
+
+ + {t("location")} + {/*improve shouldLockIndicator function to also accept eventType and then conditionally render + based on Managed Event type or not.*/} + {shouldLockIndicator("locations")} + + ( + } + setValue={formMethods.setValue as unknown as UseFormSetValue} + control={formMethods.control as unknown as Control} + formState={formMethods.formState as unknown as FormState} + {...props} + customClassNames={customClassNames?.locationSection} + /> + )} + /> +
-
+ {eventType.schedulingType === SchedulingType.ROUND_ROBIN && ( )} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 96fcc0a1c7da35..1eccb9c4881da6 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3859,11 +3859,11 @@ "license_key": "License key", "license_selection_title": "Choose your license", "license_selection_description": "Select how you want to license your Cal.com deployment", - "agplv3_license_description": "Use Cal.com under the AGPLv3 open source license", + "agplv3_license_description": "Use Cal.com under the AGPLv3 open source license", "enter_license_key": "Enter license key", "enter_your_license_key": "Access to all enterprise features", "enter_existing_license": "Starting at $15/user per month", - "enter_license_key_description": "Access to all enterprise features", + "enter_license_key_description": "Access to all enterprise features", "license_key_placeholder": "Enter your license key", "save_license_key": "Save license key", "signature_token_optional": "Signature token (optional)", @@ -4273,7 +4273,8 @@ "booking_history": "Booking history", "booking_history_description": "View the history of actions performed on this booking", "enable_custom_host_locations": "Enable custom host locations", - "enable_custom_host_locations_description": "Allow each round-robin host to have their own meeting location", + "enable_custom_host_locations_description": "Allow each host to have their own meeting location", + "locations_disabled_per_host_enabled": "Locations are controlled per-host when custom host locations is enabled", "host_locations": "Host Locations", "select_location": "Select location", "host_locations_fallback_description": "When a host doesn't have the selected app installed, Cal Video will be used as a fallback", diff --git a/packages/app-store/calendar.services.generated.ts b/packages/app-store/calendar.services.generated.ts index 9795cfe01ea444..9785bb6eb6baa8 100644 --- a/packages/app-store/calendar.services.generated.ts +++ b/packages/app-store/calendar.services.generated.ts @@ -3,18 +3,19 @@ Don't modify this file manually. **/ export const CalendarServiceMap = - process.env.NEXT_PUBLIC_IS_E2E === "1" - ? {} - : { - applecalendar: import("./applecalendar/lib/CalendarService"), - caldavcalendar: import("./caldavcalendar/lib/CalendarService"), - exchange2013calendar: import("./exchange2013calendar/lib/CalendarService"), - exchange2016calendar: import("./exchange2016calendar/lib/CalendarService"), - exchangecalendar: import("./exchangecalendar/lib/CalendarService"), - feishucalendar: import("./feishucalendar/lib/CalendarService"), - googlecalendar: import("./googlecalendar/lib/CalendarService"), - "ics-feedcalendar": import("./ics-feedcalendar/lib/CalendarService"), - larkcalendar: import("./larkcalendar/lib/CalendarService"), - office365calendar: import("./office365calendar/lib/CalendarService"), - zohocalendar: import("./zohocalendar/lib/CalendarService"), - }; + // process.env.NEXT_PUBLIC_IS_E2E === "1" + // ? {} + // : + { + applecalendar: import("./applecalendar/lib/CalendarService"), + caldavcalendar: import("./caldavcalendar/lib/CalendarService"), + exchange2013calendar: import("./exchange2013calendar/lib/CalendarService"), + exchange2016calendar: import("./exchange2016calendar/lib/CalendarService"), + exchangecalendar: import("./exchangecalendar/lib/CalendarService"), + feishucalendar: import("./feishucalendar/lib/CalendarService"), + googlecalendar: import("./googlecalendar/lib/CalendarService"), + "ics-feedcalendar": import("./ics-feedcalendar/lib/CalendarService"), + larkcalendar: import("./larkcalendar/lib/CalendarService"), + office365calendar: import("./office365calendar/lib/CalendarService"), + zohocalendar: import("./zohocalendar/lib/CalendarService"), + }; diff --git a/packages/app-store/locations.ts b/packages/app-store/locations.ts index 366827f687fd0b..fddf6ffafe47e7 100644 --- a/packages/app-store/locations.ts +++ b/packages/app-store/locations.ts @@ -301,6 +301,22 @@ export const guessEventLocationType = (locationTypeOrValue: string | undefined | export const LocationType = { ...DefaultEventLocationTypeEnum, ...AppStoreLocationType }; +export const isStaticLocationType = (locationType: string): boolean => { + return Object.values(DefaultEventLocationTypeEnum).includes(locationType as DefaultEventLocationTypeEnum); +}; + +export const isCalVideoLocation = (locationType: string): boolean => { + return locationType === DailyLocationType; +}; + +export const getAppSlugFromLocationType = (locationType: string): string | null => { + const app = locationsFromApps.find((l) => l.type === locationType); + if (app && "slug" in app) { + return app.slug as string; + } + return null; +}; + type PrivacyFilteredLocationObject = Optional; export const privacyFilteredLocations = (locations: LocationObject[]): PrivacyFilteredLocationObject[] => { diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 12ae9be92bdbfd..ca20feea135db8 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -18,7 +18,6 @@ import { MeetLocationType, OrganizerDefaultConferencingAppType, } from "@calcom/app-store/locations"; -import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { getAppFromSlug } from "@calcom/app-store/utils"; import { HostLocationRepository } from "@calcom/features/host/repositories/HostLocationRepository"; import { @@ -48,6 +47,7 @@ import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/Sp import { CreditService } from "@calcom/features/ee/billing/credit-service"; import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer"; import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder"; +import { BookingLocationService } from "@calcom/features/ee/round-robin/lib/bookingLocationService"; import { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType"; import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService"; import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository"; @@ -1312,88 +1312,23 @@ async function handler( ) { const organizerHost = eventType.hosts.find((host) => host.user.id === organizerUser.id); if (organizerHost?.location) { - const hostLocation = organizerHost.location; - // Check if the host has a valid credential for the location type - if (hostLocation.credentialId) { - // Use host's configured location with their credential - locationBodyString = hostLocation.type; - perHostCredentialId = hostLocation.credentialId; - tracingLogger.info("Using per-host location", { - userId: organizerUser.id, - locationType: hostLocation.type, - credentialId: hostLocation.credentialId, - }); - } else if (hostLocation.type === "integrations:daily") { - // Cal Video doesn't need a credential - locationBodyString = hostLocation.type; - tracingLogger.info("Using per-host Cal Video location", { - userId: organizerUser.id, - }); - } else if (hostLocation.link) { - // Static link type - locationBodyString = hostLocation.type; - organizerOrFirstDynamicGroupMemberDefaultLocationUrl = hostLocation.link; - tracingLogger.info("Using per-host link location", { - userId: organizerUser.id, - link: hostLocation.link, - }); - } else if (hostLocation.type === "inPerson") { - locationBodyString = hostLocation.address || hostLocation.type; - tracingLogger.info("Using per-host in-person location", { - userId: organizerUser.id, - }); - } else if (hostLocation.type === "userPhone") { - locationBodyString = hostLocation.phoneNumber || hostLocation.type; - tracingLogger.info("Using per-host organizer phone location", { - userId: organizerUser.id, - }); - } else if (hostLocation.type === "attendeeInPerson" || hostLocation.type === "phone") { - locationBodyString = hostLocation.type; - tracingLogger.info("Using per-host attendee-provided location", { - userId: organizerUser.id, - locationType: hostLocation.type, - }); - } else { - // Host has a conferencing app location but no credential - try to find one from allCredentials - const appMetaForLocation = Object.values(appStoreMetadata).find( - (app) => app.appData?.location?.type === hostLocation.type - ); - - if (appMetaForLocation) { - const matchingCredential = allCredentials.find((cred) => cred.type === appMetaForLocation.type); + const result = await BookingLocationService.getPerHostLocation({ + hostLocation: organizerHost.location, + allCredentials, + eventTypeId: eventType.id, + userId: organizerUser.id, + prismaClient: deps.prismaClient, + }); - if (matchingCredential) { - locationBodyString = hostLocation.type; - perHostCredentialId = matchingCredential.id; + locationBodyString = result.locationBodyString; + organizerOrFirstDynamicGroupMemberDefaultLocationUrl = result.organizerDefaultLocationUrl; + perHostCredentialId = result.perHostCredentialId; - // Link the credential to the HostLocation for future bookings - const hostLocationRepository = new HostLocationRepository(deps.prismaClient); - await hostLocationRepository.linkCredential({ - userId: organizerUser.id, - eventTypeId: eventType.id, - credentialId: matchingCredential.id, - }); - - tracingLogger.info("Found and linked credential for per-host location", { - userId: organizerUser.id, - locationType: hostLocation.type, - credentialId: matchingCredential.id, - }); - } else { - locationBodyString = "integrations:daily"; - tracingLogger.info("No credential found for per-host location, falling back to Cal Video", { - userId: organizerUser.id, - requestedLocationType: hostLocation.type, - }); - } - } else { - locationBodyString = "integrations:daily"; - tracingLogger.info("Unknown location type, falling back to Cal Video", { - userId: organizerUser.id, - requestedLocationType: hostLocation.type, - }); - } - } + tracingLogger.info("Using per-host location", { + userId: organizerUser.id, + locationType: result.locationBodyString, + credentialId: result.perHostCredentialId, + }); } } diff --git a/packages/features/bookings/types.ts b/packages/features/bookings/types.ts index 1f3454e5c498c8..e04d9985995357 100644 --- a/packages/features/bookings/types.ts +++ b/packages/features/bookings/types.ts @@ -41,6 +41,7 @@ export type BookerEvent = Pick< | "recurringEvent" | "entity" | "locations" + | "enablePerHostLocations" | "metadata" | "isDynamic" | "requiresConfirmation" diff --git a/packages/features/ee/round-robin/lib/bookingLocationService.ts b/packages/features/ee/round-robin/lib/bookingLocationService.ts index 14b2469d3bab6f..b14a76f83ce0ce 100644 --- a/packages/features/ee/round-robin/lib/bookingLocationService.ts +++ b/packages/features/ee/round-robin/lib/bookingLocationService.ts @@ -1,6 +1,9 @@ +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; 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 { HostLocationRepository } from "@calcom/features/host/repositories/HostLocationRepository"; +import type { PrismaClient } from "@calcom/prisma"; import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; type GetOrganizerDefaultConferencingAppLocationParams = { @@ -86,6 +89,28 @@ type GetLocationDetailsFromTypeResult = { conferenceCredentialId: number | null; }; +type HostLocation = { + type: string; + credentialId: number | null; + link: string | null; + address: string | null; + phoneNumber: string | null; +}; + +type GetPerHostLocationParams = { + hostLocation: HostLocation; + allCredentials: { id: number; type: string }[]; + eventTypeId: number; + userId: number; + prismaClient: PrismaClient; +}; + +type GetPerHostLocationResult = { + locationBodyString: string; + organizerDefaultLocationUrl: string | null; + perHostCredentialId: number | undefined; +}; + export class BookingLocationService { /** * Determines the booking location based on the Organizer's Default Conferencing App. @@ -244,4 +269,100 @@ export class BookingLocationService { return { bookingLocation, conferenceCredentialId: conferenceCredentialId ?? null }; } + + /** + * Resolves the location for a per-host custom location in round-robin events. + * + * Handles different location types: + * - Credential-based apps (Zoom, Teams, etc.) - Uses the host's stored credential + * - Cal Video - No credential needed + * - Static links - Returns the stored link + * - In-person - Returns the stored address + * - Phone locations - Returns the stored phone number or type + * - Unknown apps - Attempts to find and link a credential, falls back to Cal Video + */ + static async getPerHostLocation({ + hostLocation, + allCredentials, + eventTypeId, + userId, + prismaClient, + }: GetPerHostLocationParams): Promise { + if (hostLocation.credentialId) { + return { + locationBodyString: hostLocation.type, + organizerDefaultLocationUrl: null, + perHostCredentialId: hostLocation.credentialId, + }; + } + + if (hostLocation.type === CalVideoLocationType) { + return { + locationBodyString: hostLocation.type, + organizerDefaultLocationUrl: null, + perHostCredentialId: undefined, + }; + } + + if (hostLocation.link) { + return { + locationBodyString: hostLocation.type, + organizerDefaultLocationUrl: hostLocation.link, + perHostCredentialId: undefined, + }; + } + + if (hostLocation.type === "inPerson") { + return { + locationBodyString: hostLocation.address || hostLocation.type, + organizerDefaultLocationUrl: null, + perHostCredentialId: undefined, + }; + } + + if (hostLocation.type === "userPhone") { + return { + locationBodyString: hostLocation.phoneNumber || hostLocation.type, + organizerDefaultLocationUrl: null, + perHostCredentialId: undefined, + }; + } + + if (hostLocation.type === "attendeeInPerson" || hostLocation.type === "phone") { + return { + locationBodyString: hostLocation.type, + organizerDefaultLocationUrl: null, + perHostCredentialId: undefined, + }; + } + + const appMetaForLocation = Object.values(appStoreMetadata).find( + (app) => app.appData?.location?.type === hostLocation.type + ); + + if (appMetaForLocation) { + const matchingCredential = allCredentials.find((cred) => cred.type === appMetaForLocation.type); + + if (matchingCredential) { + const hostLocationRepository = new HostLocationRepository(prismaClient); + await hostLocationRepository.linkCredential({ + userId, + eventTypeId, + credentialId: matchingCredential.id, + }); + + return { + locationBodyString: hostLocation.type, + organizerDefaultLocationUrl: null, + perHostCredentialId: matchingCredential.id, + }; + } + } + + return { + locationBodyString: CalVideoLocationType, + organizerDefaultLocationUrl: null, + perHostCredentialId: undefined, + }; + } } diff --git a/packages/features/eventtypes/lib/getPublicEvent.ts b/packages/features/eventtypes/lib/getPublicEvent.ts index 2148f78e1fd856..2d0a148a69179a 100644 --- a/packages/features/eventtypes/lib/getPublicEvent.ts +++ b/packages/features/eventtypes/lib/getPublicEvent.ts @@ -68,6 +68,7 @@ export const getPublicEventSelect = (fetchAllUsers: boolean) => { schedulingType: true, length: true, locations: true, + enablePerHostLocations: true, customInputs: true, disableGuests: true, metadata: true, diff --git a/packages/features/host/repositories/HostRepository.ts b/packages/features/host/repositories/HostRepository.ts index 5b42300b8a66e5..593a5ef0a6f756 100644 --- a/packages/features/host/repositories/HostRepository.ts +++ b/packages/features/host/repositories/HostRepository.ts @@ -1,4 +1,6 @@ +import { AppCategories } from "@calcom/prisma/enums"; import type { PrismaClient } from "@calcom/prisma"; +import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; export class HostRepository { constructor(private prismaClient: PrismaClient) {} @@ -37,4 +39,47 @@ export class HostRepository { }, }); } + + async findHostsWithLocationOptions(eventTypeId: number) { + return await this.prismaClient.host.findMany({ + where: { + eventTypeId, + }, + select: { + userId: true, + isFixed: true, + priority: true, + location: { + select: { + id: true, + type: true, + credentialId: true, + link: true, + address: true, + phoneNumber: true, + }, + }, + user: { + select: { + id: true, + name: true, + email: true, + avatarUrl: true, + metadata: true, + credentials: { + where: { + app: { + categories: { + hasSome: [AppCategories.conferencing, AppCategories.video], + }, + }, + }, + select: credentialForCalendarServiceSelect, + }, + }, + }, + }, + orderBy: [{ user: { name: "asc" } }, { priority: "desc" }], + }); + } } diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts index 6b5e51301d9f32..18d1d10944a66a 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts @@ -1,8 +1,7 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { enrichUsersWithDelegationCredentials } from "@calcom/app-store/delegationCredential"; -import { AppCategories } from "@calcom/prisma/enums"; +import { HostRepository } from "@calcom/features/host/repositories/HostRepository"; import type { PrismaClient } from "@calcom/prisma"; -import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { userMetadata } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; @@ -72,46 +71,8 @@ export const getHostsWithLocationOptionsHandler = async ({ const organizationId = eventType.team?.parentId ?? null; - const hosts = await ctx.prisma.host.findMany({ - where: { - eventTypeId, - }, - select: { - userId: true, - isFixed: true, - priority: true, - location: { - select: { - id: true, - type: true, - credentialId: true, - link: true, - address: true, - phoneNumber: true, - }, - }, - user: { - select: { - id: true, - name: true, - email: true, - avatarUrl: true, - metadata: true, - credentials: { - where: { - app: { - categories: { - hasSome: [AppCategories.conferencing, AppCategories.video], - }, - }, - }, - select: credentialForCalendarServiceSelect, - }, - }, - }, - }, - orderBy: [{ user: { name: "asc" } }, { priority: "desc" }], - }); + const hostRepository = new HostRepository(ctx.prisma); + const hosts = await hostRepository.findHostsWithLocationOptions(eventTypeId); const usersForEnrichment = hosts.map((host) => ({ id: host.user.id, From 85eb061e79f251cb76ff982d6b8410134e4a3a35 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 09:03:51 +0000 Subject: [PATCH 11/29] fix: correct grammar in custom host locations tooltip Change 'custom host locations is enabled' to 'custom host locations are enabled' (plural subject requires plural verb). Addresses Cubic AI review feedback (confidence 9/10). Co-Authored-By: unknown <> --- apps/web/public/static/locales/en/common.json | 2 +- .../app-store/calendar.services.generated.ts | 31 +++++++++---------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 1eccb9c4881da6..930131ed4b3d50 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4274,7 +4274,7 @@ "booking_history_description": "View the history of actions performed on this booking", "enable_custom_host_locations": "Enable custom host locations", "enable_custom_host_locations_description": "Allow each host to have their own meeting location", - "locations_disabled_per_host_enabled": "Locations are controlled per-host when custom host locations is enabled", + "locations_disabled_per_host_enabled": "Locations are controlled per-host when custom host locations are enabled", "host_locations": "Host Locations", "select_location": "Select location", "host_locations_fallback_description": "When a host doesn't have the selected app installed, Cal Video will be used as a fallback", diff --git a/packages/app-store/calendar.services.generated.ts b/packages/app-store/calendar.services.generated.ts index 9785bb6eb6baa8..9795cfe01ea444 100644 --- a/packages/app-store/calendar.services.generated.ts +++ b/packages/app-store/calendar.services.generated.ts @@ -3,19 +3,18 @@ Don't modify this file manually. **/ export const CalendarServiceMap = - // process.env.NEXT_PUBLIC_IS_E2E === "1" - // ? {} - // : - { - applecalendar: import("./applecalendar/lib/CalendarService"), - caldavcalendar: import("./caldavcalendar/lib/CalendarService"), - exchange2013calendar: import("./exchange2013calendar/lib/CalendarService"), - exchange2016calendar: import("./exchange2016calendar/lib/CalendarService"), - exchangecalendar: import("./exchangecalendar/lib/CalendarService"), - feishucalendar: import("./feishucalendar/lib/CalendarService"), - googlecalendar: import("./googlecalendar/lib/CalendarService"), - "ics-feedcalendar": import("./ics-feedcalendar/lib/CalendarService"), - larkcalendar: import("./larkcalendar/lib/CalendarService"), - office365calendar: import("./office365calendar/lib/CalendarService"), - zohocalendar: import("./zohocalendar/lib/CalendarService"), - }; + process.env.NEXT_PUBLIC_IS_E2E === "1" + ? {} + : { + applecalendar: import("./applecalendar/lib/CalendarService"), + caldavcalendar: import("./caldavcalendar/lib/CalendarService"), + exchange2013calendar: import("./exchange2013calendar/lib/CalendarService"), + exchange2016calendar: import("./exchange2016calendar/lib/CalendarService"), + exchangecalendar: import("./exchangecalendar/lib/CalendarService"), + feishucalendar: import("./feishucalendar/lib/CalendarService"), + googlecalendar: import("./googlecalendar/lib/CalendarService"), + "ics-feedcalendar": import("./ics-feedcalendar/lib/CalendarService"), + larkcalendar: import("./larkcalendar/lib/CalendarService"), + office365calendar: import("./office365calendar/lib/CalendarService"), + zohocalendar: import("./zohocalendar/lib/CalendarService"), + }; From 42df0e7c56d997a40c36983bdf2588d0f2eaccb8 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Wed, 14 Jan 2026 16:01:57 +0530 Subject: [PATCH 12/29] refactor: improvements --- .../components/locations/HostLocations.tsx | 616 ++++++++++++------ apps/web/public/static/locales/en/common.json | 11 +- .../host/repositories/HostRepository.ts | 59 ++ .../routers/viewer/eventTypes/_router.ts | 12 + .../getHostsWithLocationOptions.handler.ts | 98 ++- .../getHostsWithLocationOptions.schema.ts | 2 + .../massApplyHostLocation.handler.ts | 110 ++++ .../massApplyHostLocation.schema.ts | 11 + 8 files changed, 696 insertions(+), 223 deletions(-) create mode 100644 packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.handler.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.schema.ts diff --git a/apps/web/modules/event-types/components/locations/HostLocations.tsx b/apps/web/modules/event-types/components/locations/HostLocations.tsx index 92f2acc77e20ce..1197387f461208 100644 --- a/apps/web/modules/event-types/components/locations/HostLocations.tsx +++ b/apps/web/modules/event-types/components/locations/HostLocations.tsx @@ -1,10 +1,13 @@ "use client"; import { useSession } from "next-auth/react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useFormContext } from "react-hook-form"; +import { components } from "react-select"; +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { + defaultLocations, getAppSlugFromLocationType, getEventLocationType, isCalVideoLocation, @@ -12,19 +15,20 @@ import { } from "@calcom/app-store/locations"; import { getAppFromSlug } from "@calcom/app-store/utils"; import PhoneInput from "@calcom/features/components/phone-input"; +import invertLogoOnDark from "@calcom/lib/invertLogoOnDark"; import type { LocationOption } from "@calcom/features/form/components/LocationSelect"; import LocationSelect from "@calcom/features/form/components/LocationSelect"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; +import { Alert } from "@calcom/ui/components/alert"; import { Avatar } from "@calcom/ui/components/avatar"; import { Badge } from "@calcom/ui/components/badge"; import { Button } from "@calcom/ui/components/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog"; -import { Label, TextField } from "@calcom/ui/components/form"; -import { Select } from "@calcom/ui/components/form"; -import { SettingsToggle } from "@calcom/ui/components/form"; +import { Label, TextField, Select, SettingsToggle } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; import { Skeleton } from "@calcom/ui/components/skeleton"; +import { showToast } from "@calcom/ui/components/toast"; import type { FormValues, Host, HostLocation } from "../../lib/types"; import type { TLocationOptions } from "./Locations"; @@ -308,6 +312,8 @@ const HostLocationRow = ({ isSearchable={false} className="w-72 text-sm" menuPlacement="auto" + menuPortalTarget={typeof document !== "undefined" ? document.body : null} + styles={{ menuPortal: (base) => ({ ...base, zIndex: 9999 }) }} onChange={handleLocationSelect} /> {hasOrganizerInput && currentLocation && ( @@ -328,185 +334,454 @@ const HostLocationRow = ({ ); }; -const MassApplySelect = ({ - locationOptions, - onApply, -}: { - locationOptions: TLocationOptions; - onApply: (locationType: string) => void; -}) => { +type AllLocationOption = { + value: string; + label: string; + icon?: string; +}; + +const getAllLocationOptions = (): AllLocationOption[] => { + const options: AllLocationOption[] = []; + const seenValues = new Set(); + + defaultLocations.forEach((loc) => { + if (!seenValues.has(loc.type)) { + seenValues.add(loc.type); + options.push({ + value: loc.type, + label: loc.label, + icon: loc.iconUrl, + }); + } + }); + + Object.values(appStoreMetadata).forEach((app) => { + const locationData = app.appData?.location; + if (locationData && !seenValues.has(locationData.type)) { + seenValues.add(locationData.type); + options.push({ + value: locationData.type, + label: locationData.label || app.name, + icon: app.logo, + }); + } + }); + + return options; +}; + +type MassApplyLocationDialogProps = { + isOpen: boolean; + onClose: () => void; + onApply: (locationType: string, inputValue?: string) => void; + isApplying: boolean; +}; + +const useMassApplyDialogState = (isOpen: boolean) => { + const [selectedType, setSelectedType] = useState(null); + const [inputValue, setInputValue] = useState(""); + + useEffect(() => { + if (!isOpen) { + setSelectedType(null); + setInputValue(""); + } + }, [isOpen]); + + const reset = () => { + setSelectedType(null); + setInputValue(""); + }; + + return { selectedType, setSelectedType, inputValue, setInputValue, reset }; +}; + +const MassApplyLocationDialog = ({ isOpen, onClose, onApply, isApplying }: MassApplyLocationDialogProps) => { const { t } = useLocale(); + const { selectedType, setSelectedType, inputValue, setInputValue, reset } = useMassApplyDialogState(isOpen); + const allLocationOptions = useMemo(() => getAllLocationOptions(), []); - const options = useMemo(() => { - return locationOptions.flatMap((group) => - group.options.map((opt) => ({ - value: opt.value, - label: opt.label, - })) - ); - }, [locationOptions, t]); + const selectedOption = allLocationOptions.find((o) => o.value === selectedType); + const eventLocationType = selectedType ? getEventLocationType(selectedType) : null; + const needsInput = eventLocationType?.organizerInputType; + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleApply = () => { + if (!selectedType) return; + onApply(selectedType, inputValue || undefined); + }; + + const getTranslatedLabel = (opt: AllLocationOption) => { + const translated = t(opt.label); + return translated !== opt.label ? translated : opt.label; + }; + + const selectOptions = allLocationOptions.map((opt) => ({ + value: opt.value, + label: getTranslatedLabel(opt), + icon: opt.icon, + })); + const selectedTranslatedLabel = selectedOption ? getTranslatedLabel(selectedOption) : null; + const selectValue = selectedOption + ? { value: selectedOption.value, label: selectedTranslatedLabel, icon: selectedOption.icon } + : null; + + const OptionWithIcon = ({ icon, label }: { icon?: string; label: string | null }) => ( +
+ {icon && } + {label} +
+ ); return ( - { + setSelectedType(option?.value || null); + setInputValue(""); + }} + options={selectOptions} + className="w-full" + components={{ + Option: (props) => ( + + + + ), + SingleValue: (props) => ( + + + + ), + }} + /> +
+ {needsInput && eventLocationType && ( + + )} + + + + + + +
+
); }; -export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsProps) => { - const { t } = useLocale(); - const session = useSession(); - const formMethods = useFormContext(); - - const [isMassApplyDialogOpen, setIsMassApplyDialogOpen] = useState(false); - const [pendingMassApplyOption, setPendingMassApplyOption] = useState(null); +type LocationInputFieldProps = { + eventLocationType: ReturnType; + inputValue: string; + setInputValue: (value: string) => void; +}; - const isOrg = !!session.data?.user?.org?.id; +const LocationInputField = ({ eventLocationType, inputValue, setInputValue }: LocationInputFieldProps) => { + const { t } = useLocale(); + if (!eventLocationType) return null; - const enablePerHostLocations = formMethods.watch("enablePerHostLocations"); - const hosts = formMethods.watch("hosts"); + return ( +
+ + {eventLocationType.organizerInputType === "phone" ? ( + setInputValue(val || "")} + placeholder={t(eventLocationType.organizerInputPlaceholder || "")} + /> + ) : ( + setInputValue(e.target.value)} + placeholder={t(eventLocationType.organizerInputPlaceholder || "")} + type="text" + /> + )} +
+ ); +}; - const { data: hostsWithApps, isLoading } = trpc.viewer.eventTypes.getHostsWithLocationOptions.useQuery( - { eventTypeId }, - { enabled: enablePerHostLocations && eventTypeId > 0 } +const useInfiniteScroll = ( + loadMoreRef: React.RefObject, + hasNextPage: boolean | undefined, + isFetchingNextPage: boolean, + fetchNextPage: () => void +) => { + const handleObserver = useCallback( + (entries: IntersectionObserverEntry[]) => { + const [entry] = entries; + if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + [hasNextPage, isFetchingNextPage, fetchNextPage] ); - const hostDataMap = useMemo(() => { - if (!hostsWithApps) return new Map(); - return new Map(hostsWithApps.map((h) => [h.userId, h])); - }, [hostsWithApps]); + useEffect(() => { + const element = loadMoreRef.current; + if (!element) return; + + const observer = new IntersectionObserver(handleObserver, { threshold: 0.1 }); + observer.observe(element); - const mergedLocationOptions = useMemo(() => { - if (!hostsWithApps) return locationOptions; + return () => observer.disconnect(); + }, [handleObserver, loadMoreRef]); +}; - const existingValues = new Set(); - locationOptions.forEach((group) => { - group.options.forEach((opt) => existingValues.add(opt.value)); +const mergeLocationOptionsWithHostApps = ( + locationOptions: TLocationOptions, + hostsWithApps: HostWithLocationOptions[], + t: (key: string) => string +): TLocationOptions => { + if (hostsWithApps.length === 0) return locationOptions; + + const existingValues = new Set(); + locationOptions.forEach((group) => { + group.options.forEach((opt) => existingValues.add(opt.value)); + }); + + const hostAppsOptions: TLocationOptions[number]["options"] = []; + hostsWithApps.forEach((host) => { + host.installedApps.forEach((app) => { + if (app.locationOption && !existingValues.has(app.locationOption.value)) { + existingValues.add(app.locationOption.value); + hostAppsOptions.push({ + value: app.locationOption.value, + label: app.locationOption.label, + icon: app.locationOption.icon, + }); + } }); + }); - const hostAppsOptions: TLocationOptions[number]["options"] = []; - hostsWithApps.forEach((host) => { - host.installedApps.forEach((app) => { - if (app.locationOption && !existingValues.has(app.locationOption.value)) { - existingValues.add(app.locationOption.value); - hostAppsOptions.push({ - value: app.locationOption.value, - label: app.locationOption.label, - icon: app.locationOption.icon, - }); - } - }); + if (hostAppsOptions.length === 0) return locationOptions; + + const conferencingGroup = locationOptions.find( + (g) => g.label.toLowerCase().includes("conferencing") || g.label.toLowerCase().includes("video") + ); + + if (conferencingGroup) { + return locationOptions.map((group) => { + if (group === conferencingGroup) { + return { ...group, options: [...group.options, ...hostAppsOptions] }; + } + return group; }); + } - if (hostAppsOptions.length === 0) return locationOptions; + return [...locationOptions, { label: t("conferencing"), options: hostAppsOptions }]; +}; - const conferencingGroup = locationOptions.find( - (g) => g.label.toLowerCase().includes("conferencing") || g.label.toLowerCase().includes("video") +type HostListProps = { + hosts: Host[]; + hostDataMap: Map; + locationOptions: TLocationOptions; + onLocationChange: (userId: number, location: HostLocation | null) => void; + loadMoreRef: React.RefObject; + isLoading: boolean; + isFetchingNextPage: boolean; + onOpenMassApply: () => void; +}; + +const HostList = ({ + hosts, + hostDataMap, + locationOptions, + onLocationChange, + loadMoreRef, + isLoading, + isFetchingNextPage, + onOpenMassApply, +}: HostListProps) => { + const { t } = useLocale(); + + return ( +
+
+ + {t("host_locations")} + + +
+ +
+ {isLoading ? ( +
+ +
+ ) : ( + <> + {hosts.map((host) => ( + + ))} +
+ {isFetchingNextPage && ( +
+ +
+ )} + + )} +
+ +

{t("host_locations_fallback_description")}

+
+ ); +}; + +const useHostLocationsData = (eventTypeId: number, enabled: boolean, locationOptions: TLocationOptions) => { + const { t } = useLocale(); + const loadMoreRef = useRef(null); + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + trpc.viewer.eventTypes.getHostsWithLocationOptions.useInfiniteQuery( + { eventTypeId, limit: 10 }, + { enabled: enabled && eventTypeId > 0, getNextPageParam: (lastPage) => lastPage.nextCursor } ); - if (conferencingGroup) { - return locationOptions.map((group) => { - if (group === conferencingGroup) { - return { - ...group, - options: [...group.options, ...hostAppsOptions], - }; - } - return group; - }); - } + const hostsWithApps = useMemo(() => data?.pages.flatMap((page) => page.hosts) ?? [], [data]); + const hostDataMap = useMemo(() => new Map(hostsWithApps.map((h) => [h.userId, h])), [hostsWithApps]); + const mergedLocationOptions = useMemo( + () => mergeLocationOptionsWithHostApps(locationOptions, hostsWithApps, t), + [locationOptions, hostsWithApps, t] + ); - return [...locationOptions, { label: t("conferencing"), options: hostAppsOptions }]; - }, [locationOptions, hostsWithApps, t]); + useInfiniteScroll(loadMoreRef, hasNextPage, isFetchingNextPage, fetchNextPage); + + return { hostDataMap, mergedLocationOptions, loadMoreRef, isLoading, isFetchingNextPage }; +}; +const useHostLocationHandlers = ( + formMethods: ReturnType>, + hosts: Host[] +) => { const handleToggle = (checked: boolean) => { formMethods.setValue("enablePerHostLocations", checked, { shouldDirty: true }); if (!checked) { - const updatedHosts = hosts.map((host) => ({ - ...host, - location: null, - })); - formMethods.setValue("hosts", updatedHosts, { shouldDirty: true }); + formMethods.setValue( + "hosts", + hosts.map((host) => ({ ...host, location: null })), + { shouldDirty: true } + ); } }; const handleLocationChange = (userId: number, location: HostLocation | null) => { - const updatedHosts = hosts.map((host) => { - if (host.userId === userId) { - return { ...host, location }; - } - return host; - }); - formMethods.setValue("hosts", updatedHosts, { shouldDirty: true }); + formMethods.setValue( + "hosts", + hosts.map((h) => (h.userId === userId ? { ...h, location } : h)), + { shouldDirty: true } + ); }; - const applyLocationToAllHosts = (locationType: string, inputValue: string | null) => { - const eventLocationType = getEventLocationType(locationType); - - const updatedHosts = hosts.map((host) => { - const hostData = hostDataMap.get(host.userId); - const credential = hostData?.installedApps.find( - (app) => app.appId === getAppSlugFromLocationType(locationType) || app.type === locationType - ); + return { handleToggle, handleLocationChange }; +}; - const location: HostLocation = { - userId: host.userId, - eventTypeId: 0, - type: locationType, - credentialId: credential?.credentialId ?? null, - }; - - if (inputValue && eventLocationType) { - if (eventLocationType.defaultValueVariable === "link") { - location.link = inputValue; - } else if (eventLocationType.defaultValueVariable === "address") { - location.address = inputValue; - } else if (eventLocationType.organizerInputType === "phone") { - location.phoneNumber = inputValue; - } +const useMassApplyMutation = ( + eventTypeId: number, + formMethods: ReturnType>, + hosts: Host[], + onSuccess: () => void +) => { + const { t } = useLocale(); + const utils = trpc.useUtils(); + + const mutation = trpc.viewer.eventTypes.massApplyHostLocation.useMutation({ + onError: (error) => showToast(error.message, "error"), + }); + + const handleMassApply = (locationType: string, inputValue?: string) => { + const evtLocType = getEventLocationType(locationType); + const link = evtLocType?.defaultValueVariable === "link" ? inputValue : undefined; + const address = evtLocType?.defaultValueVariable === "address" ? inputValue : undefined; + const phoneNumber = evtLocType?.organizerInputType === "phone" ? inputValue : undefined; + + mutation.mutate( + { eventTypeId, locationType, link, address, phoneNumber }, + { + onSuccess: (result) => { + const updatedHosts = hosts.map((host) => ({ + ...host, + location: { + userId: host.userId, + eventTypeId, + type: locationType, + credentialId: null, + link: link ?? null, + address: address ?? null, + phoneNumber: phoneNumber ?? null, + }, + })); + formMethods.setValue("hosts", updatedHosts, { shouldDirty: true }); + + showToast(t("location_applied_to_hosts", { count: result.updatedCount }), "success"); + onSuccess(); + utils.viewer.eventTypes.getHostsWithLocationOptions.invalidate({ eventTypeId }); + }, } - - return { ...host, location }; - }); - formMethods.setValue("hosts", updatedHosts, { shouldDirty: true }); + ); }; - const handleMassApply = (locationType: string) => { - const eventLocationType = getEventLocationType(locationType); + return { handleMassApply, isPending: mutation.isPending }; +}; - if (eventLocationType?.organizerInputType) { - const option = getLocationFromOptions(locationType, mergedLocationOptions); - setPendingMassApplyOption(option || null); - setIsMassApplyDialogOpen(true); - return; - } +const UpgradeBadge = () => { + const { t } = useLocale(); + return ( + + {t("upgrade")} + + ); +}; - applyLocationToAllHosts(locationType, null); - }; +export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsProps) => { + const { t } = useLocale(); + const session = useSession(); + const formMethods = useFormContext(); + const [isMassApplyDialogOpen, setIsMassApplyDialogOpen] = useState(false); - const handleMassApplyDialogSave = (inputValue: string) => { - if (pendingMassApplyOption) { - applyLocationToAllHosts(pendingMassApplyOption.value, inputValue); - } - setPendingMassApplyOption(null); - }; + const isOrg = !!session.data?.user?.org?.id; + const enablePerHostLocations = formMethods.watch("enablePerHostLocations"); + const hosts = formMethods.watch("hosts"); - const handleMassApplyDialogClose = () => { - setIsMassApplyDialogOpen(false); - setPendingMassApplyOption(null); - }; + const { hostDataMap, mergedLocationOptions, loadMoreRef, isLoading, isFetchingNextPage } = + useHostLocationsData(eventTypeId, enablePerHostLocations, locationOptions); + const { handleToggle, handleLocationChange } = useHostLocationHandlers(formMethods, hosts); + const { handleMassApply, isPending } = useMassApplyMutation(eventTypeId, formMethods, hosts, () => + setIsMassApplyDialogOpen(false) + ); - if (hosts.length === 0) { - return null; - } + if (hosts.length === 0) return null; return (
@@ -517,53 +792,26 @@ export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsPro checked={enablePerHostLocations} onCheckedChange={handleToggle} disabled={!isOrg} - Badge={ - !isOrg ? ( - - {t("upgrade")} - - ) : undefined - } + Badge={!isOrg ? : undefined} /> - {enablePerHostLocations && ( -
-
- - {t("host_locations")} - - -
- -
- {isLoading ? ( -
- -
- ) : ( - hosts.map((host) => ( - - )) - )} -
- -

{t("host_locations_fallback_description")}

-
+ setIsMassApplyDialogOpen(true)} + /> )}
- setIsMassApplyDialogOpen(false)} + onApply={handleMassApply} + isApplying={isPending} />
); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 930131ed4b3d50..f22cf892f821e2 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1,5 +1,4 @@ { - "set_location_for_all_hosts": "Set location for all hosts", "apply_to_all": "Apply to all", "identity_provider": "Identity provider", "trial_days_left": "You have $t(day, {\"count\": {{days}} }) left on your pro trial", @@ -4274,11 +4273,17 @@ "booking_history_description": "View the history of actions performed on this booking", "enable_custom_host_locations": "Enable custom host locations", "enable_custom_host_locations_description": "Allow each host to have their own meeting location", - "locations_disabled_per_host_enabled": "Locations are controlled per-host when custom host locations are enabled", - "host_locations": "Host Locations", + "locations_disabled_per_host_enabled": "Locations are controlled per-host when custom host locations option is enabled", + "host_locations": "Configure locations per host", "select_location": "Select location", "host_locations_fallback_description": "When a host doesn't have the selected app installed, Cal Video will be used as a fallback", "select_option_hosts": "Apply to all hosts", + "set_location_for_all_hosts": "Set location for all hosts", + "apply_location_to_all_hosts": "Apply location to all hosts", + "select_location_type": "Select location type", + "mass_apply_fallback_title": "Fallback behavior", + "mass_apply_fallback_explanation": "Hosts without this app installed will use Cal Video. If they install the app later, their preferred location will be used automatically.", + "location_applied_to_hosts": "Location applied to {{count}} hosts", "booking_audit_action": { "created": "Booked with {{host}}", "created_with_seat": "Seat Booked with {{host}}", diff --git a/packages/features/host/repositories/HostRepository.ts b/packages/features/host/repositories/HostRepository.ts index 593a5ef0a6f756..42454fdf2f23fb 100644 --- a/packages/features/host/repositories/HostRepository.ts +++ b/packages/features/host/repositories/HostRepository.ts @@ -82,4 +82,63 @@ export class HostRepository { orderBy: [{ user: { name: "asc" } }, { priority: "desc" }], }); } + + async findHostsWithLocationOptionsPaginated({ + eventTypeId, + cursor, + limit = 10, + }: { + eventTypeId: number; + cursor?: number; + limit?: number; + }) { + const hosts = await this.prismaClient.host.findMany({ + where: { + eventTypeId, + ...(cursor && { userId: { gt: cursor } }), + }, + take: limit + 1, + select: { + userId: true, + isFixed: true, + priority: true, + location: { + select: { + id: true, + type: true, + credentialId: true, + link: true, + address: true, + phoneNumber: true, + }, + }, + user: { + select: { + id: true, + name: true, + email: true, + avatarUrl: true, + metadata: true, + credentials: { + where: { + app: { + categories: { + hasSome: [AppCategories.conferencing, AppCategories.video], + }, + }, + }, + select: credentialForCalendarServiceSelect, + }, + }, + }, + }, + orderBy: [{ userId: "asc" }], + }); + + const hasMore = hosts.length > limit; + const items = hasMore ? hosts.slice(0, -1) : hosts; + const nextCursor = hasMore ? items[items.length - 1].userId : undefined; + + return { items, nextCursor, hasMore }; + } } diff --git a/packages/trpc/server/routers/viewer/eventTypes/_router.ts b/packages/trpc/server/routers/viewer/eventTypes/_router.ts index 78bbc4b7aeab4e..f13ad04ced01a4 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/_router.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/_router.ts @@ -11,6 +11,7 @@ import { ZEventTypeInputSchema, ZGetEventTypesFromGroupSchema } from "./getByVie import { ZGetHashedLinkInputSchema } from "./getHashedLink.schema"; import { ZGetHashedLinksInputSchema } from "./getHashedLinks.schema"; import { ZGetHostsWithLocationOptionsInputSchema } from "./getHostsWithLocationOptions.schema"; +import { ZMassApplyHostLocationInputSchema } from "./massApplyHostLocation.schema"; import { get } from "./procedures/get"; import { createEventPbacProcedure } from "./util"; @@ -157,4 +158,15 @@ export const eventTypesRouter = router({ input, }); }), + + massApplyHostLocation: authedProcedure + .input(ZMassApplyHostLocationInputSchema) + .mutation(async ({ ctx, input }) => { + const { massApplyHostLocationHandler } = await import("./massApplyHostLocation.handler"); + + return massApplyHostLocationHandler({ + ctx, + input, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts index 18d1d10944a66a..b76b3d34fd8f48 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts @@ -17,6 +17,16 @@ type GetHostsWithLocationOptionsInput = { input: TGetHostsWithLocationOptionsInputSchema; }; +type HostFromRepository = Awaited< + ReturnType +>["items"][number]; + +type EnrichedCredential = { + id: number; + appId: string | null; + type: string; +}; + export type HostWithLocationOptions = { userId: number; name: string | null; @@ -46,22 +56,57 @@ export type HostWithLocationOptions = { }[]; }; +export type GetHostsWithLocationOptionsResponse = { + hosts: HostWithLocationOptions[]; + nextCursor: number | undefined; + hasMore: boolean; +}; + +export function transformHostsToResponse( + hosts: HostFromRepository[], + enrichedUsers: { credentials: EnrichedCredential[] }[] +): HostWithLocationOptions[] { + const appMetadataBySlug = new Map(Object.values(appStoreMetadata).map((app) => [app.slug, app])); + + return hosts.map((host, index) => ({ + userId: host.userId, + name: host.user.name, + email: host.user.email, + avatarUrl: host.user.avatarUrl, + defaultConferencingApp: userMetadata.parse(host.user.metadata)?.defaultConferencingApp ?? null, + location: host.location, + installedApps: enrichedUsers[index].credentials.map((cred) => { + const appMeta = cred.appId ? appMetadataBySlug.get(cred.appId) : null; + const locationData = appMeta?.appData?.location; + + return { + appId: cred.appId, + credentialId: cred.id, + type: cred.type, + locationOption: locationData + ? { + value: locationData.type, + label: locationData.label || appMeta?.name || cred.appId || "", + icon: appMeta?.logo, + } + : undefined, + }; + }), + })); +} + export const getHostsWithLocationOptionsHandler = async ({ ctx, input, -}: GetHostsWithLocationOptionsInput): Promise => { - const { eventTypeId } = input; +}: GetHostsWithLocationOptionsInput): Promise => { + const { eventTypeId, cursor, limit } = input; const eventType = await ctx.prisma.eventType.findUnique({ where: { id: eventTypeId }, select: { id: true, teamId: true, - team: { - select: { - parentId: true, - }, - }, + team: { select: { parentId: true } }, }, }); @@ -70,9 +115,12 @@ export const getHostsWithLocationOptionsHandler = async ({ } const organizationId = eventType.team?.parentId ?? null; - const hostRepository = new HostRepository(ctx.prisma); - const hosts = await hostRepository.findHostsWithLocationOptions(eventTypeId); + const { items: hosts, nextCursor, hasMore } = await hostRepository.findHostsWithLocationOptionsPaginated({ + eventTypeId, + cursor, + limit, + }); const usersForEnrichment = hosts.map((host) => ({ id: host.user.id, @@ -85,31 +133,9 @@ export const getHostsWithLocationOptionsHandler = async ({ users: usersForEnrichment, }); - const appMetadataBySlug = new Map(Object.values(appStoreMetadata).map((app) => [app.slug, app])); - - return hosts.map((host, index) => ({ - userId: host.userId, - name: host.user.name, - email: host.user.email, - avatarUrl: host.user.avatarUrl, - defaultConferencingApp: userMetadata.parse(host.user.metadata)?.defaultConferencingApp ?? null, - location: host.location, - installedApps: enrichedUsers[index].credentials.map((cred) => { - const appMeta = cred.appId ? appMetadataBySlug.get(cred.appId) : null; - const locationData = appMeta?.appData?.location; - - return { - appId: cred.appId, - credentialId: cred.id, - type: cred.type, - locationOption: locationData - ? { - value: locationData.type, - label: locationData.label || appMeta?.name || cred.appId || "", - icon: appMeta?.logo, - } - : undefined, - }; - }), - })); + return { + hosts: transformHostsToResponse(hosts, enrichedUsers), + nextCursor, + hasMore, + }; }; diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.schema.ts index 8bcb652293d7d0..b85e7d3da10354 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.schema.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.schema.ts @@ -2,6 +2,8 @@ import { z } from "zod"; export const ZGetHostsWithLocationOptionsInputSchema = z.object({ eventTypeId: z.number(), + cursor: z.number().optional(), + limit: z.number().min(1).max(100).default(10), }); export type TGetHostsWithLocationOptionsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.handler.ts new file mode 100644 index 00000000000000..9bc016fa54b82e --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.handler.ts @@ -0,0 +1,110 @@ +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import type { PrismaClient } from "@calcom/prisma"; +import { AppCategories } from "@calcom/prisma/enums"; +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TMassApplyHostLocationInputSchema } from "./massApplyHostLocation.schema"; + +type MassApplyHostLocationInput = { + ctx: { + user: NonNullable; + prisma: PrismaClient; + }; + input: TMassApplyHostLocationInputSchema; +}; + +type MassApplyHostLocationResponse = { + success: boolean; + updatedCount: number; +}; + +const findCredentialIdForLocationType = ( + locationType: string, + credentials: { id: number; type: string; appId: string | null }[] +): number | null => { + const appMeta = Object.values(appStoreMetadata).find( + (app) => app.appData?.location?.type === locationType + ); + if (!appMeta) return null; + + const matchingCredential = credentials.find( + (cred) => cred.type === appMeta.type || cred.appId === appMeta.slug + ); + return matchingCredential?.id ?? null; +}; + +export const massApplyHostLocationHandler = async ({ + ctx, + input, +}: MassApplyHostLocationInput): Promise => { + const { eventTypeId, locationType, link, address, phoneNumber } = input; + + const eventType = await ctx.prisma.eventType.findUnique({ + where: { id: eventTypeId }, + select: { id: true, teamId: true }, + }); + + if (!eventType) { + throw new TRPCError({ code: "NOT_FOUND", message: "Event type not found" }); + } + + const hosts = await ctx.prisma.host.findMany({ + where: { eventTypeId }, + select: { + userId: true, + user: { + select: { + credentials: { + where: { + app: { + categories: { + hasSome: [AppCategories.conferencing, AppCategories.video], + }, + }, + }, + select: { + id: true, + type: true, + appId: true, + }, + }, + }, + }, + }, + }); + + if (hosts.length === 0) { + return { success: true, updatedCount: 0 }; + } + + await ctx.prisma.$transaction( + hosts.map((host) => { + const credentialId = findCredentialIdForLocationType(locationType, host.user.credentials); + + return ctx.prisma.hostLocation.upsert({ + where: { + userId_eventTypeId: { userId: host.userId, eventTypeId }, + }, + create: { + eventTypeId, + userId: host.userId, + type: locationType, + link: link || null, + address: address || null, + phoneNumber: phoneNumber || null, + credentialId, + }, + update: { + type: locationType, + link: link || null, + address: address || null, + phoneNumber: phoneNumber || null, + credentialId, + }, + }); + }) + ); + + return { success: true, updatedCount: hosts.length }; +}; diff --git a/packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.schema.ts new file mode 100644 index 00000000000000..d3cbeb8f5b05af --- /dev/null +++ b/packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const ZMassApplyHostLocationInputSchema = z.object({ + eventTypeId: z.number(), + locationType: z.string(), + link: z.string().optional(), + address: z.string().optional(), + phoneNumber: z.string().optional(), +}); + +export type TMassApplyHostLocationInputSchema = z.infer; From 9bfdba0b41997985f22ac9073d9bf50adb614414 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Wed, 14 Jan 2026 18:31:07 +0530 Subject: [PATCH 13/29] fix: auth --- packages/trpc/server/routers/viewer/eventTypes/_router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trpc/server/routers/viewer/eventTypes/_router.ts b/packages/trpc/server/routers/viewer/eventTypes/_router.ts index f13ad04ced01a4..4dafec9a6ee3e2 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/_router.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/_router.ts @@ -159,7 +159,7 @@ export const eventTypesRouter = router({ }); }), - massApplyHostLocation: authedProcedure + massApplyHostLocation: createEventPbacProcedure("eventType.update", [MembershipRole.ADMIN, MembershipRole.OWNER]) .input(ZMassApplyHostLocationInputSchema) .mutation(async ({ ctx, input }) => { const { massApplyHostLocationHandler } = await import("./massApplyHostLocation.handler"); From f11d6edf7df932bd16c72d9ef6dc921a97abbc67 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Wed, 14 Jan 2026 18:35:15 +0530 Subject: [PATCH 14/29] fix: check --- packages/app-store/locations.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/app-store/locations.ts b/packages/app-store/locations.ts index fddf6ffafe47e7..2752ad233d1fc5 100644 --- a/packages/app-store/locations.ts +++ b/packages/app-store/locations.ts @@ -310,9 +310,10 @@ export const isCalVideoLocation = (locationType: string): boolean => { }; export const getAppSlugFromLocationType = (locationType: string): string | null => { - const app = locationsFromApps.find((l) => l.type === locationType); - if (app && "slug" in app) { - return app.slug as string; + for (const [, meta] of Object.entries(appStoreMetadata)) { + if (meta.appData?.location?.type === locationType) { + return meta.slug; + } } return null; }; From da77c956db2bc0b59bd0276ae26d385d0cf40aa0 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Thu, 15 Jan 2026 18:21:33 +0530 Subject: [PATCH 15/29] refactor: improvements --- .../components/locations/HostLocations.tsx | 59 +++++++++---------- apps/web/public/static/locales/en/common.json | 2 +- .../lib/service/RegularBookingService.ts | 1 - .../routers/viewer/eventTypes/_router.ts | 10 +++- .../getHostsWithLocationOptions.handler.ts | 2 +- 5 files changed, 38 insertions(+), 36 deletions(-) diff --git a/apps/web/modules/event-types/components/locations/HostLocations.tsx b/apps/web/modules/event-types/components/locations/HostLocations.tsx index 1197387f461208..75ec2a403872c3 100644 --- a/apps/web/modules/event-types/components/locations/HostLocations.tsx +++ b/apps/web/modules/event-types/components/locations/HostLocations.tsx @@ -203,7 +203,7 @@ const HostLocationRow = ({ }, [currentLocation, hostData]); const displayName = hostData?.name || `${t("user")} ${host.userId}`; - const avatarUrl = hostData?.avatarUrl || ""; + const avatarUrl = hostData?.avatarUrl || undefined; const currentLocationEventType = currentLocation ? getEventLocationType(currentLocation.type) : null; const hasOrganizerInput = !!currentLocationEventType?.organizerInputType; @@ -293,7 +293,7 @@ const HostLocationRow = ({ return ( <>
- +
{displayName}
{hostData?.email &&
{hostData.email}
} @@ -523,31 +523,29 @@ const LocationInputField = ({ eventLocationType, inputValue, setInputValue }: Lo ); }; -const useInfiniteScroll = ( - loadMoreRef: React.RefObject, +const useFetchMoreOnScroll = ( + containerRef: React.RefObject, hasNextPage: boolean | undefined, isFetchingNextPage: boolean, fetchNextPage: () => void ) => { - const handleObserver = useCallback( - (entries: IntersectionObserverEntry[]) => { - const [entry] = entries; - if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }, - [hasNextPage, isFetchingNextPage, fetchNextPage] - ); + const handleScroll = useCallback(() => { + const container = containerRef.current; + if (!container || !hasNextPage || isFetchingNextPage) return; - useEffect(() => { - const element = loadMoreRef.current; - if (!element) return; + const { scrollHeight, scrollTop, clientHeight } = container; + if (scrollHeight - scrollTop - clientHeight < 100) { + fetchNextPage(); + } + }, [containerRef, hasNextPage, isFetchingNextPage, fetchNextPage]); - const observer = new IntersectionObserver(handleObserver, { threshold: 0.1 }); - observer.observe(element); + useEffect(() => { + const container = containerRef.current; + if (!container) return; - return () => observer.disconnect(); - }, [handleObserver, loadMoreRef]); + container.addEventListener("scroll", handleScroll); + return () => container.removeEventListener("scroll", handleScroll); + }, [handleScroll, containerRef]); }; const mergeLocationOptionsWithHostApps = ( @@ -599,7 +597,7 @@ type HostListProps = { hostDataMap: Map; locationOptions: TLocationOptions; onLocationChange: (userId: number, location: HostLocation | null) => void; - loadMoreRef: React.RefObject; + containerRef: React.RefObject; isLoading: boolean; isFetchingNextPage: boolean; onOpenMassApply: () => void; @@ -610,7 +608,7 @@ const HostList = ({ hostDataMap, locationOptions, onLocationChange, - loadMoreRef, + containerRef, isLoading, isFetchingNextPage, onOpenMassApply, @@ -618,9 +616,9 @@ const HostList = ({ const { t } = useLocale(); return ( -
+
- + {t("host_locations")}
-
+
{isLoading ? (
@@ -644,7 +642,6 @@ const HostList = ({ onLocationChange={onLocationChange} /> ))} -
{isFetchingNextPage && (
@@ -661,7 +658,7 @@ const HostList = ({ const useHostLocationsData = (eventTypeId: number, enabled: boolean, locationOptions: TLocationOptions) => { const { t } = useLocale(); - const loadMoreRef = useRef(null); + const containerRef = useRef(null); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = trpc.viewer.eventTypes.getHostsWithLocationOptions.useInfiniteQuery( @@ -676,9 +673,9 @@ const useHostLocationsData = (eventTypeId: number, enabled: boolean, locationOpt [locationOptions, hostsWithApps, t] ); - useInfiniteScroll(loadMoreRef, hasNextPage, isFetchingNextPage, fetchNextPage); + useFetchMoreOnScroll(containerRef, hasNextPage, isFetchingNextPage, fetchNextPage); - return { hostDataMap, mergedLocationOptions, loadMoreRef, isLoading, isFetchingNextPage }; + return { hostDataMap, mergedLocationOptions, containerRef, isLoading, isFetchingNextPage }; }; const useHostLocationHandlers = ( @@ -774,7 +771,7 @@ export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsPro const enablePerHostLocations = formMethods.watch("enablePerHostLocations"); const hosts = formMethods.watch("hosts"); - const { hostDataMap, mergedLocationOptions, loadMoreRef, isLoading, isFetchingNextPage } = + const { hostDataMap, mergedLocationOptions, containerRef, isLoading, isFetchingNextPage } = useHostLocationsData(eventTypeId, enablePerHostLocations, locationOptions); const { handleToggle, handleLocationChange } = useHostLocationHandlers(formMethods, hosts); const { handleMassApply, isPending } = useMassApplyMutation(eventTypeId, formMethods, hosts, () => @@ -800,7 +797,7 @@ export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsPro hostDataMap={hostDataMap} locationOptions={mergedLocationOptions} onLocationChange={handleLocationChange} - loadMoreRef={loadMoreRef} + containerRef={containerRef} isLoading={isLoading} isFetchingNextPage={isFetchingNextPage} onOpenMassApply={() => setIsMassApplyDialogOpen(true)} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index f22cf892f821e2..b473d157973527 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4274,7 +4274,7 @@ "enable_custom_host_locations": "Enable custom host locations", "enable_custom_host_locations_description": "Allow each host to have their own meeting location", "locations_disabled_per_host_enabled": "Locations are controlled per-host when custom host locations option is enabled", - "host_locations": "Configure locations per host", + "host_locations": "Configure location per host", "select_location": "Select location", "host_locations_fallback_description": "When a host doesn't have the selected app installed, Cal Video will be used as a fallback", "select_option_hosts": "Apply to all hosts", diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index ca20feea135db8..d997069c78fae7 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -19,7 +19,6 @@ import { OrganizerDefaultConferencingAppType, } from "@calcom/app-store/locations"; import { getAppFromSlug } from "@calcom/app-store/utils"; -import { HostLocationRepository } from "@calcom/features/host/repositories/HostLocationRepository"; import { eventTypeMetaDataSchemaWithTypedApps, eventTypeAppMetadataOptionalSchema, diff --git a/packages/trpc/server/routers/viewer/eventTypes/_router.ts b/packages/trpc/server/routers/viewer/eventTypes/_router.ts index 4dafec9a6ee3e2..cb8d664f0a32b5 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/_router.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/_router.ts @@ -148,7 +148,10 @@ export const eventTypesRouter = router({ }); }), - getHostsWithLocationOptions: authedProcedure + getHostsWithLocationOptions: createEventPbacProcedure("eventType.update", [ + MembershipRole.ADMIN, + MembershipRole.OWNER, + ]) .input(ZGetHostsWithLocationOptionsInputSchema) .query(async ({ ctx, input }) => { const { getHostsWithLocationOptionsHandler } = await import("./getHostsWithLocationOptions.handler"); @@ -159,7 +162,10 @@ export const eventTypesRouter = router({ }); }), - massApplyHostLocation: createEventPbacProcedure("eventType.update", [MembershipRole.ADMIN, MembershipRole.OWNER]) + massApplyHostLocation: createEventPbacProcedure("eventType.update", [ + MembershipRole.ADMIN, + MembershipRole.OWNER, + ]) .input(ZMassApplyHostLocationInputSchema) .mutation(async ({ ctx, input }) => { const { massApplyHostLocationHandler } = await import("./massApplyHostLocation.handler"); diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts index b76b3d34fd8f48..e0859b552a2485 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts @@ -73,7 +73,7 @@ export function transformHostsToResponse( name: host.user.name, email: host.user.email, avatarUrl: host.user.avatarUrl, - defaultConferencingApp: userMetadata.parse(host.user.metadata)?.defaultConferencingApp ?? null, + defaultConferencingApp: userMetadata.safeParse(host.user.metadata).data?.defaultConferencingApp ?? null, location: host.location, installedApps: enrichedUsers[index].credentials.map((cred) => { const appMeta = cred.appId ? appMetadataBySlug.get(cred.appId) : null; From 999dc2ee1ef20e8189311ec48b22e00dd07d0240 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:11:35 +0000 Subject: [PATCH 16/29] fix: address Cubic AI review feedback (confidence >= 9/10) - Add scheduleId to newly created hosts in update.handler.ts to persist host-specific schedules during create operations - Change host location deletion filter from !host.location to host.location === null to only delete when explicitly set to null - Fix static-link per-host locations to use actual link instead of type in locationBodyString for bookingLocationService.ts Co-Authored-By: unknown <> --- .../features/ee/round-robin/lib/bookingLocationService.ts | 2 +- .../server/routers/viewer/eventTypes/heavy/update.handler.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/features/ee/round-robin/lib/bookingLocationService.ts b/packages/features/ee/round-robin/lib/bookingLocationService.ts index b14a76f83ce0ce..d6d066d821f106 100644 --- a/packages/features/ee/round-robin/lib/bookingLocationService.ts +++ b/packages/features/ee/round-robin/lib/bookingLocationService.ts @@ -306,7 +306,7 @@ export class BookingLocationService { if (hostLocation.link) { return { - locationBodyString: hostLocation.type, + locationBodyString: hostLocation.link, organizerDefaultLocationUrl: hostLocation.link, perHostCredentialId: undefined, }; diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts index 739977733b4d73..30cc72ed405d5d 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts @@ -506,6 +506,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { priority: number; weight: number; groupId: string | null | undefined; + scheduleId?: number | null | undefined; location?: { create: { type: string; @@ -521,6 +522,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { priority: host.priority ?? 2, weight: host.weight ?? 100, groupId: host.groupId, + scheduleId: host.scheduleId ?? null, }; if (host.location) { hostData.location = { @@ -600,7 +602,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }; // Delete host locations for hosts that no longer have locations - const hostsWithoutLocations = existingHosts.filter((host) => !host.location); + const hostsWithoutLocations = existingHosts.filter((host) => host.location === null); if (hostsWithoutLocations.length > 0) { await ctx.prisma.hostLocation.deleteMany({ where: { From 9b901e45b483fc5caf26c70fc736fb06075de4e9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:19:01 +0000 Subject: [PATCH 17/29] fix: preserve existing host scheduleId when not explicitly provided Change scheduleId handling for existing hosts from 'host.scheduleId ?? null' to 'host.scheduleId === undefined ? undefined : host.scheduleId' so that when the client doesn't provide a scheduleId, the existing value is preserved instead of being cleared to null. Co-Authored-By: unknown <> --- .../server/routers/viewer/eventTypes/heavy/update.handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts index 30cc72ed405d5d..32dc951fc647bd 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts @@ -566,7 +566,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, priority: host.priority ?? 2, weight: host.weight ?? 100, - scheduleId: host.scheduleId ?? null, + scheduleId: host.scheduleId === undefined ? undefined : host.scheduleId, groupId: host.groupId, }; if (host.location) { From 1173a90c1ff98f2a532d70754c65105e95706a18 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Thu, 15 Jan 2026 19:02:02 +0530 Subject: [PATCH 18/29] fix; type erro --- apps/web/modules/bookings/components/EventMeta.tsx | 1 + .../event-types/components/locations/HostLocations.tsx | 8 ++++---- apps/web/test/lib/handleChildrenEventTypes.test.ts | 4 ++++ packages/features/host/repositories/HostRepository.ts | 6 +++--- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/web/modules/bookings/components/EventMeta.tsx b/apps/web/modules/bookings/components/EventMeta.tsx index 9811fe5e3eb262..deb8c85e6746f8 100644 --- a/apps/web/modules/bookings/components/EventMeta.tsx +++ b/apps/web/modules/bookings/components/EventMeta.tsx @@ -80,6 +80,7 @@ export const EventMeta = ({ | "isDynamic" | "fieldTranslations" | "autoTranslateDescriptionEnabled" + | "enablePerHostLocations" > | null; isPending: boolean; isPrivateLink: boolean; diff --git a/apps/web/modules/event-types/components/locations/HostLocations.tsx b/apps/web/modules/event-types/components/locations/HostLocations.tsx index 75ec2a403872c3..5e22e2b9b26301 100644 --- a/apps/web/modules/event-types/components/locations/HostLocations.tsx +++ b/apps/web/modules/event-types/components/locations/HostLocations.tsx @@ -30,7 +30,7 @@ import { Icon } from "@calcom/ui/components/icon"; import { Skeleton } from "@calcom/ui/components/skeleton"; import { showToast } from "@calcom/ui/components/toast"; -import type { FormValues, Host, HostLocation } from "../../lib/types"; +import type { FormValues, Host, HostLocation } from "@calcom/features/eventtypes/lib/types"; import type { TLocationOptions } from "./Locations"; type HostWithLocationOptions = { @@ -313,7 +313,7 @@ const HostLocationRow = ({ className="w-72 text-sm" menuPlacement="auto" menuPortalTarget={typeof document !== "undefined" ? document.body : null} - styles={{ menuPortal: (base) => ({ ...base, zIndex: 9999 }) }} + styles={{ menuPortal: (base, _props) => ({ ...base, zIndex: 9999 }) }} onChange={handleLocationSelect} /> {hasOrganizerInput && currentLocation && ( @@ -524,7 +524,7 @@ const LocationInputField = ({ eventLocationType, inputValue, setInputValue }: Lo }; const useFetchMoreOnScroll = ( - containerRef: React.RefObject, + containerRef: React.RefObject, hasNextPage: boolean | undefined, isFetchingNextPage: boolean, fetchNextPage: () => void @@ -597,7 +597,7 @@ type HostListProps = { hostDataMap: Map; locationOptions: TLocationOptions; onLocationChange: (userId: number, location: HostLocation | null) => void; - containerRef: React.RefObject; + containerRef: React.RefObject; isLoading: boolean; isFetchingNextPage: boolean; onOpenMassApply: () => void; diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index 90bb7a7ae79491..79e9156d1e705d 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -166,6 +166,7 @@ describe("handleChildrenEventTypes", () => { autoTranslateInstantMeetingTitleEnabled, includeNoShowInRRCalculation, instantMeetingScheduleId, + enablePerHostLocations, }; prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]); @@ -370,6 +371,7 @@ describe("handleChildrenEventTypes", () => { includeNoShowInRRCalculation, instantMeetingScheduleId, assignRRMembersUsingSegment, + enablePerHostLocations, }; prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]); @@ -537,6 +539,7 @@ describe("handleChildrenEventTypes", () => { includeNoShowInRRCalculation, instantMeetingScheduleId, assignRRMembersUsingSegment, + enablePerHostLocations, }; prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]); @@ -559,6 +562,7 @@ describe("handleChildrenEventTypes", () => { instantMeetingScheduleId: null, assignRRMembersUsingSegment: false, includeNoShowInRRCalculation: false, + enablePerHostLocations, ...evType, }; diff --git a/packages/features/host/repositories/HostRepository.ts b/packages/features/host/repositories/HostRepository.ts index 42454fdf2f23fb..1489daa9ba61ba 100644 --- a/packages/features/host/repositories/HostRepository.ts +++ b/packages/features/host/repositories/HostRepository.ts @@ -1,6 +1,6 @@ import { AppCategories } from "@calcom/prisma/enums"; import type { PrismaClient } from "@calcom/prisma"; -import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; +import { safeCredentialSelect } from "@calcom/prisma/selects/credential"; export class HostRepository { constructor(private prismaClient: PrismaClient) {} @@ -74,7 +74,7 @@ export class HostRepository { }, }, }, - select: credentialForCalendarServiceSelect, + select: safeCredentialSelect, }, }, }, @@ -127,7 +127,7 @@ export class HostRepository { }, }, }, - select: credentialForCalendarServiceSelect, + select: safeCredentialSelect, }, }, }, From 9a8da54200f336e22c64cfa4a6df33437d4b112f Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Thu, 15 Jan 2026 19:35:59 +0530 Subject: [PATCH 19/29] fix; type erro --- .../components/locations/HostLocations.tsx | 6 +++++- .../getHostsWithLocationOptions.handler.ts | 13 ++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/web/modules/event-types/components/locations/HostLocations.tsx b/apps/web/modules/event-types/components/locations/HostLocations.tsx index 5e22e2b9b26301..4ad1d24c8a1c8c 100644 --- a/apps/web/modules/event-types/components/locations/HostLocations.tsx +++ b/apps/web/modules/event-types/components/locations/HostLocations.tsx @@ -313,7 +313,11 @@ const HostLocationRow = ({ className="w-72 text-sm" menuPlacement="auto" menuPortalTarget={typeof document !== "undefined" ? document.body : null} - styles={{ menuPortal: (base, _props) => ({ ...base, zIndex: 9999 }) }} + styles={{ + menuPortal: (base) => { + return { ...base, zIndex: 9999 }; + }, + }} onChange={handleLocationSelect} /> {hasOrganizerInput && currentLocation && ( diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts index e0859b552a2485..228ca74f9e6786 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts @@ -1,7 +1,7 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { enrichUsersWithDelegationCredentials } from "@calcom/app-store/delegationCredential"; import { HostRepository } from "@calcom/features/host/repositories/HostRepository"; -import type { PrismaClient } from "@calcom/prisma"; +import type { PrismaClient, Prisma } from "@calcom/prisma/client"; import { userMetadata } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; @@ -116,7 +116,11 @@ export const getHostsWithLocationOptionsHandler = async ({ const organizationId = eventType.team?.parentId ?? null; const hostRepository = new HostRepository(ctx.prisma); - const { items: hosts, nextCursor, hasMore } = await hostRepository.findHostsWithLocationOptionsPaginated({ + const { + items: hosts, + nextCursor, + hasMore, + } = await hostRepository.findHostsWithLocationOptionsPaginated({ eventTypeId, cursor, limit, @@ -125,7 +129,10 @@ export const getHostsWithLocationOptionsHandler = async ({ const usersForEnrichment = hosts.map((host) => ({ id: host.user.id, email: host.user.email, - credentials: host.user.credentials, + credentials: host.user.credentials.map((cred) => ({ + ...cred, + key: {} as Prisma.JsonValue, + })), })); const enrichedUsers = await enrichUsersWithDelegationCredentials({ From 9335417257649aefd5819ca0eae0e17a1815a8b1 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Thu, 15 Jan 2026 19:48:10 +0530 Subject: [PATCH 20/29] fix; type erro --- .../event-types/components/locations/HostLocations.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/modules/event-types/components/locations/HostLocations.tsx b/apps/web/modules/event-types/components/locations/HostLocations.tsx index 4ad1d24c8a1c8c..dc694b6b20db35 100644 --- a/apps/web/modules/event-types/components/locations/HostLocations.tsx +++ b/apps/web/modules/event-types/components/locations/HostLocations.tsx @@ -3,6 +3,7 @@ import { useSession } from "next-auth/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useFormContext } from "react-hook-form"; +import type { CSSObjectWithLabel } from "react-select"; import { components } from "react-select"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; @@ -314,9 +315,7 @@ const HostLocationRow = ({ menuPlacement="auto" menuPortalTarget={typeof document !== "undefined" ? document.body : null} styles={{ - menuPortal: (base) => { - return { ...base, zIndex: 9999 }; - }, + menuPortal: (base) => ({ ...base, zIndex: 9999 }) as CSSObjectWithLabel, }} onChange={handleLocationSelect} /> From 1611ed1f00e518f5323358164f5445799edf3607 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Fri, 16 Jan 2026 02:41:36 +0530 Subject: [PATCH 21/29] refactor: move repository --- .../repositories/eventTypeRepository.ts | 10 +++ .../repositories/HostLocationRepository.ts | 33 +++++++++ .../host/repositories/HostRepository.ts | 27 +++++++ .../massApplyHostLocation.handler.ts | 73 +++++-------------- 4 files changed, 88 insertions(+), 55 deletions(-) diff --git a/packages/features/eventtypes/repositories/eventTypeRepository.ts b/packages/features/eventtypes/repositories/eventTypeRepository.ts index dd56d5ac590ca0..1e51d0f770b42c 100644 --- a/packages/features/eventtypes/repositories/eventTypeRepository.ts +++ b/packages/features/eventtypes/repositories/eventTypeRepository.ts @@ -1605,6 +1605,16 @@ export class EventTypeRepository { }); } + async findByIdWithTeamId({ id }: { id: number }) { + return await this.prismaClient.eventType.findUnique({ + where: { id }, + select: { + id: true, + teamId: true, + }, + }); + } + async findByIdWithParent(eventTypeId: number) { return this.prismaClient.eventType.findUnique({ where: { id: eventTypeId }, diff --git a/packages/features/host/repositories/HostLocationRepository.ts b/packages/features/host/repositories/HostLocationRepository.ts index 3159ab87a0ad16..a5e26ef3f3d072 100644 --- a/packages/features/host/repositories/HostLocationRepository.ts +++ b/packages/features/host/repositories/HostLocationRepository.ts @@ -24,4 +24,37 @@ export class HostLocationRepository { }, }); } + + async upsertMany( + locations: { + userId: number; + eventTypeId: number; + type: string; + link: string | null; + address: string | null; + phoneNumber: string | null; + credentialId: number | null; + }[] + ) { + return await Promise.all( + locations.map((location) => + this.prismaClient.hostLocation.upsert({ + where: { + userId_eventTypeId: { + userId: location.userId, + eventTypeId: location.eventTypeId, + }, + }, + create: location, + update: { + type: location.type, + link: location.link, + address: location.address, + phoneNumber: location.phoneNumber, + credentialId: location.credentialId, + }, + }) + ) + ); + } } diff --git a/packages/features/host/repositories/HostRepository.ts b/packages/features/host/repositories/HostRepository.ts index 1489daa9ba61ba..4369ab0be70dd9 100644 --- a/packages/features/host/repositories/HostRepository.ts +++ b/packages/features/host/repositories/HostRepository.ts @@ -141,4 +141,31 @@ export class HostRepository { return { items, nextCursor, hasMore }; } + + async findHostsWithConferencingCredentials(eventTypeId: number) { + return await this.prismaClient.host.findMany({ + where: { eventTypeId }, + select: { + userId: true, + user: { + select: { + credentials: { + where: { + app: { + categories: { + hasSome: [AppCategories.conferencing, AppCategories.video], + }, + }, + }, + select: { + id: true, + type: true, + appId: true, + }, + }, + }, + }, + }, + }); + } } diff --git a/packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.handler.ts index 9bc016fa54b82e..48d43cb61f1422 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.handler.ts @@ -1,6 +1,8 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; +import { HostRepository } from "@calcom/features/host/repositories/HostRepository"; +import { HostLocationRepository } from "@calcom/features/host/repositories/HostLocationRepository"; import type { PrismaClient } from "@calcom/prisma"; -import { AppCategories } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; import type { TrpcSessionUser } from "../../../types"; @@ -40,71 +42,32 @@ export const massApplyHostLocationHandler = async ({ }: MassApplyHostLocationInput): Promise => { const { eventTypeId, locationType, link, address, phoneNumber } = input; - const eventType = await ctx.prisma.eventType.findUnique({ - where: { id: eventTypeId }, - select: { id: true, teamId: true }, - }); + const eventTypeRepo = new EventTypeRepository(ctx.prisma); + const eventType = await eventTypeRepo.findByIdWithTeamId({ id: eventTypeId }); if (!eventType) { throw new TRPCError({ code: "NOT_FOUND", message: "Event type not found" }); } - const hosts = await ctx.prisma.host.findMany({ - where: { eventTypeId }, - select: { - userId: true, - user: { - select: { - credentials: { - where: { - app: { - categories: { - hasSome: [AppCategories.conferencing, AppCategories.video], - }, - }, - }, - select: { - id: true, - type: true, - appId: true, - }, - }, - }, - }, - }, - }); + const hostRepo = new HostRepository(ctx.prisma); + const hosts = await hostRepo.findHostsWithConferencingCredentials(eventTypeId); if (hosts.length === 0) { return { success: true, updatedCount: 0 }; } - await ctx.prisma.$transaction( - hosts.map((host) => { - const credentialId = findCredentialIdForLocationType(locationType, host.user.credentials); + const hostLocationRepo = new HostLocationRepository(ctx.prisma); + const locations = hosts.map((host) => ({ + userId: host.userId, + eventTypeId, + type: locationType, + link: link || null, + address: address || null, + phoneNumber: phoneNumber || null, + credentialId: findCredentialIdForLocationType(locationType, host.user.credentials), + })); - return ctx.prisma.hostLocation.upsert({ - where: { - userId_eventTypeId: { userId: host.userId, eventTypeId }, - }, - create: { - eventTypeId, - userId: host.userId, - type: locationType, - link: link || null, - address: address || null, - phoneNumber: phoneNumber || null, - credentialId, - }, - update: { - type: locationType, - link: link || null, - address: address || null, - phoneNumber: phoneNumber || null, - credentialId, - }, - }); - }) - ); + await hostLocationRepo.upsertMany(locations); return { success: true, updatedCount: hosts.length }; }; From 5b323fe6e9f931926e035e43adad36327378d75d Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Fri, 16 Jan 2026 02:46:37 +0530 Subject: [PATCH 22/29] refactor: move repository --- .../getHostsWithLocationOptions.handler.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts index 228ca74f9e6786..aedbaf2ca25bab 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts @@ -1,5 +1,6 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { enrichUsersWithDelegationCredentials } from "@calcom/app-store/delegationCredential"; +import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; import { HostRepository } from "@calcom/features/host/repositories/HostRepository"; import type { PrismaClient, Prisma } from "@calcom/prisma/client"; import { userMetadata } from "@calcom/prisma/zod-utils"; @@ -101,20 +102,14 @@ export const getHostsWithLocationOptionsHandler = async ({ }: GetHostsWithLocationOptionsInput): Promise => { const { eventTypeId, cursor, limit } = input; - const eventType = await ctx.prisma.eventType.findUnique({ - where: { id: eventTypeId }, - select: { - id: true, - teamId: true, - team: { select: { parentId: true } }, - }, - }); + const eventTypeRepo = new EventTypeRepository(ctx.prisma); + const eventType = await eventTypeRepo.findByIdWithTeamId({ id: eventTypeId }); if (!eventType) { throw new TRPCError({ code: "NOT_FOUND", message: "Event type not found" }); } - const organizationId = eventType.team?.parentId ?? null; + const organizationId = ctx.user.organizationId ?? null; const hostRepository = new HostRepository(ctx.prisma); const { items: hosts, From 182c20f87f54db02c4b244619c9b4f36f92c1d74 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 21:32:22 +0000 Subject: [PATCH 23/29] fix: add singular/plural translations for location_applied_to_hosts Addresses Cubic AI review feedback (confidence 9/10) to fix '1 hosts' rendering as '1 host' by using i18next plural format with _one and _other suffixes. Co-Authored-By: unknown <> --- apps/web/public/static/locales/en/common.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index b473d157973527..dd88ee203b7181 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4283,7 +4283,8 @@ "select_location_type": "Select location type", "mass_apply_fallback_title": "Fallback behavior", "mass_apply_fallback_explanation": "Hosts without this app installed will use Cal Video. If they install the app later, their preferred location will be used automatically.", - "location_applied_to_hosts": "Location applied to {{count}} hosts", + "location_applied_to_hosts_one": "Location applied to {{count}} host", + "location_applied_to_hosts_other": "Location applied to {{count}} hosts", "booking_audit_action": { "created": "Booked with {{host}}", "created_with_seat": "Seat Booked with {{host}}", From 557bdf5c82e6f4eeb46afd5c2e88550f133a1afa Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Fri, 16 Jan 2026 14:38:26 +0530 Subject: [PATCH 24/29] refactor: feedback --- .../components/locations/HostLocations.tsx | 6 +++- .../test/per-host-locations.test.ts | 3 ++ .../viewer/eventTypes/heavy/update.handler.ts | 28 ++++++++++--------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/apps/web/modules/event-types/components/locations/HostLocations.tsx b/apps/web/modules/event-types/components/locations/HostLocations.tsx index dc694b6b20db35..68e81be81601e7 100644 --- a/apps/web/modules/event-types/components/locations/HostLocations.tsx +++ b/apps/web/modules/event-types/components/locations/HostLocations.tsx @@ -331,7 +331,11 @@ const HostLocationRow = ({ onSave={handleDialogSave} title="set_location" saveButtonText="save" - initialValue={currentLocationValue} + initialValue={ + pendingLocationOption && pendingLocationOption.value !== currentLocation?.type + ? "" + : currentLocationValue + } /> ); diff --git a/packages/features/bookings/lib/handleNewBooking/test/per-host-locations.test.ts b/packages/features/bookings/lib/handleNewBooking/test/per-host-locations.test.ts index b36da94695d34f..bcdd8c461019df 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/per-host-locations.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/per-host-locations.test.ts @@ -485,6 +485,9 @@ describe("Per-Host Locations - handleNewBooking", () => { eventTypeId: 1, }, }, + select: { + credentialId: true, + }, }); expect(hostLocation?.credentialId).not.toBeNull(); }); diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts index 32dc951fc647bd..d692eca7af7fe4 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts @@ -600,19 +600,6 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }; }), }; - - // Delete host locations for hosts that no longer have locations - const hostsWithoutLocations = existingHosts.filter((host) => host.location === null); - if (hostsWithoutLocations.length > 0) { - await ctx.prisma.hostLocation.deleteMany({ - where: { - OR: hostsWithoutLocations.map((host) => ({ - userId: host.userId, - eventTypeId: id, - })), - }, - }); - } } if (input.metadata?.disableStandardEmails?.all) { @@ -799,6 +786,21 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { } throw e; } + + if (existingHosts?.length) { + const hostsWithoutLocations = existingHosts.filter((host) => host.location === null); + if (hostsWithoutLocations.length > 0) { + await ctx.prisma.hostLocation.deleteMany({ + where: { + OR: hostsWithoutLocations.map((host) => ({ + userId: host.userId, + eventTypeId: id, + })), + }, + }); + } + } + const updatedValues = Object.entries(data).reduce((acc, [key, value]) => { if (value !== undefined) { // @ts-expect-error Element implicitly has any type From 5b0b459d98e906b345dc0b66bf0034a444a48d9d Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Fri, 16 Jan 2026 15:14:25 +0530 Subject: [PATCH 25/29] fix: type err --- .../viewer/eventTypes/heavy/update.handler.ts | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts index d692eca7af7fe4..c46416759391f2 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts @@ -473,6 +473,8 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }); } + let hostLocationDeletions: { userId: number; eventTypeId: number }[] = []; + if (teamId && hosts) { // check if all hosts can be assigned (memberships that have accepted invite) const teamMemberIds = await membershipRepo.listAcceptedTeamMemberIds({ teamId }); @@ -489,6 +491,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { const newHostsSet = new Set(hosts.map((oldHost) => oldHost.userId)); const existingHosts = hosts.filter((newHost) => oldHostsSet.has(newHost.userId)); + hostLocationDeletions = existingHosts + .filter((host) => host.location === null) + .map((host) => ({ userId: host.userId, eventTypeId: id })); const newHosts = hosts.filter((newHost) => !oldHostsSet.has(newHost.userId)); const removedHosts = eventType.hosts.filter((oldHost) => !newHostsSet.has(oldHost.userId)); @@ -787,18 +792,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { throw e; } - if (existingHosts?.length) { - const hostsWithoutLocations = existingHosts.filter((host) => host.location === null); - if (hostsWithoutLocations.length > 0) { - await ctx.prisma.hostLocation.deleteMany({ - where: { - OR: hostsWithoutLocations.map((host) => ({ - userId: host.userId, - eventTypeId: id, - })), - }, - }); - } + if (hostLocationDeletions.length > 0) { + await ctx.prisma.hostLocation.deleteMany({ + where: { + OR: hostLocationDeletions, + }, + }); } const updatedValues = Object.entries(data).reduce((acc, [key, value]) => { From 8ac0853a236fdf3a367670662c23251eeccf13df Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Fri, 16 Jan 2026 20:58:57 +0530 Subject: [PATCH 26/29] fix: use uuid in schema and remove attendee locaiton --- .../components/locations/HostLocations.tsx | 29 +++++++++++++++---- packages/features/eventtypes/lib/types.ts | 2 +- .../migration.sql | 2 +- packages/prisma/schema.prisma | 2 +- 4 files changed, 26 insertions(+), 9 deletions(-) rename packages/prisma/migrations/{20251216094314_add_host_custom_location => 20260116145525_add_custom_host_location}/migration.sql (98%) diff --git a/apps/web/modules/event-types/components/locations/HostLocations.tsx b/apps/web/modules/event-types/components/locations/HostLocations.tsx index 68e81be81601e7..a1c7ab7e756e24 100644 --- a/apps/web/modules/event-types/components/locations/HostLocations.tsx +++ b/apps/web/modules/event-types/components/locations/HostLocations.tsx @@ -44,7 +44,7 @@ type HostWithLocationOptions = { appLink?: string; } | null; location: { - id: number; + id: string; type: string; credentialId: number | null; link: string | null; @@ -79,6 +79,18 @@ const getLocationFromOptions = ( return undefined; }; +const filterOutBookerInputLocations = (options: TLocationOptions): TLocationOptions => { + return options + .map((group) => ({ + ...group, + options: group.options.filter((opt) => { + const locationType = getEventLocationType(opt.value); + return !locationType?.attendeeInputType; + }), + })) + .filter((group) => group.options.length > 0); +}; + type LocationInputDialogProps = { isOpen: boolean; onClose: () => void; @@ -406,7 +418,12 @@ const useMassApplyDialogState = (isOpen: boolean) => { const MassApplyLocationDialog = ({ isOpen, onClose, onApply, isApplying }: MassApplyLocationDialogProps) => { const { t } = useLocale(); const { selectedType, setSelectedType, inputValue, setInputValue, reset } = useMassApplyDialogState(isOpen); - const allLocationOptions = useMemo(() => getAllLocationOptions(), []); + const allLocationOptions = useMemo(() => { + return getAllLocationOptions().filter((opt) => { + const locationType = getEventLocationType(opt.value); + return !locationType?.attendeeInputType; + }); + }, []); const selectedOption = allLocationOptions.find((o) => o.value === selectedType); const eventLocationType = selectedType ? getEventLocationType(selectedType) : null; @@ -675,10 +692,10 @@ const useHostLocationsData = (eventTypeId: number, enabled: boolean, locationOpt const hostsWithApps = useMemo(() => data?.pages.flatMap((page) => page.hosts) ?? [], [data]); const hostDataMap = useMemo(() => new Map(hostsWithApps.map((h) => [h.userId, h])), [hostsWithApps]); - const mergedLocationOptions = useMemo( - () => mergeLocationOptionsWithHostApps(locationOptions, hostsWithApps, t), - [locationOptions, hostsWithApps, t] - ); + const mergedLocationOptions = useMemo(() => { + const merged = mergeLocationOptionsWithHostApps(locationOptions, hostsWithApps, t); + return filterOutBookerInputLocations(merged); + }, [locationOptions, hostsWithApps, t]); useFetchMoreOnScroll(containerRef, hasNextPage, isFetchingNextPage, fetchNextPage); diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 1816a5ec58ed22..39c3c51ed1e1be 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -28,7 +28,7 @@ export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"]; export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]; export type EventTypeApps = RouterOutputs["viewer"]["apps"]["integrations"]; export type HostLocation = { - id?: number; + id?: string; userId: number; eventTypeId: number; type: EventLocationType["type"]; diff --git a/packages/prisma/migrations/20251216094314_add_host_custom_location/migration.sql b/packages/prisma/migrations/20260116145525_add_custom_host_location/migration.sql similarity index 98% rename from packages/prisma/migrations/20251216094314_add_host_custom_location/migration.sql rename to packages/prisma/migrations/20260116145525_add_custom_host_location/migration.sql index cbf7b1370f85fd..55c5b4d79a6487 100644 --- a/packages/prisma/migrations/20251216094314_add_host_custom_location/migration.sql +++ b/packages/prisma/migrations/20260116145525_add_custom_host_location/migration.sql @@ -3,7 +3,7 @@ ALTER TABLE "public"."EventType" ADD COLUMN "enablePerHostLocations" BOOLEAN -- CreateTable CREATE TABLE "public"."HostLocation" ( - "id" SERIAL NOT NULL, + "id" TEXT NOT NULL, "userId" INTEGER NOT NULL, "eventTypeId" INTEGER NOT NULL, "type" TEXT NOT NULL, diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 4ab04791e1111f..5cc4a7eed88cec 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -98,7 +98,7 @@ model HostGroup { } model HostLocation { - id Int @id @default(autoincrement()) + id String @id @default(uuid()) userId Int eventTypeId Int host Host @relation(fields: [userId, eventTypeId], references: [userId, eventTypeId], onDelete: Cascade) From 0cb7b72e3beb8c190d7c3be4be88fbe6b51f9c89 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Fri, 16 Jan 2026 21:02:31 +0530 Subject: [PATCH 27/29] fix: type err --- .../viewer/eventTypes/getHostsWithLocationOptions.handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts index aedbaf2ca25bab..e8612706260828 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts @@ -38,7 +38,7 @@ export type HostWithLocationOptions = { appLink?: string; } | null; location: { - id: number; + id: string; type: string; credentialId: number | null; link: string | null; From 6963a48298d5ca99402e2ee620610d42dfed7cc0 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Fri, 16 Jan 2026 21:10:46 +0530 Subject: [PATCH 28/29] fix: type err --- packages/trpc/server/routers/viewer/eventTypes/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts index ad0d6a571685ed..3a6bac919239eb 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/types.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -60,7 +60,7 @@ type CalVideoSettings = { } | null; type HostLocationInput = { - id?: number; + id?: string; userId: number; eventTypeId: number; type: string; @@ -290,7 +290,7 @@ const calVideoSettingsSchema: z.ZodType = z .nullable(); const hostLocationSchema = z.object({ - id: z.number().optional(), + id: z.string().optional(), userId: z.number(), eventTypeId: z.number(), type: z.string(), From 93d41bf47b0e767545b1587d2ec712f3421b050a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:59:24 +0000 Subject: [PATCH 29/29] fix: validate eventTypeId as integer in massApplyHostLocation schema Co-Authored-By: unknown <> --- .../routers/viewer/eventTypes/massApplyHostLocation.schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.schema.ts b/packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.schema.ts index d3cbeb8f5b05af..8b3a8b16705660 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.schema.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/massApplyHostLocation.schema.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const ZMassApplyHostLocationInputSchema = z.object({ - eventTypeId: z.number(), + eventTypeId: z.number().int(), locationType: z.string(), link: z.string().optional(), address: z.string().optional(),