From 0ad436700516ea337bc4f41b1117083709155c24 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Fri, 23 Jan 2026 19:48:43 +0530 Subject: [PATCH] feat: Custom host location (#25916) * chore: save progress * chore: * feat: add input dialog * fix: type error * feat: mass apply dialog * test: per host location * test: fix test * 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 <> * fix: translation * refactor: improvements * 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 <> * refactor: improvements * fix: auth * fix: check * refactor: improvements * 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 <> * 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 <> * fix; type erro * fix; type erro * fix; type erro * refactor: move repository * refactor: move repository * 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 <> * refactor: feedback * fix: type err * fix: use uuid in schema and remove attendee locaiton * fix: type err * fix: type err * fix: validate eventTypeId as integer in massApplyHostLocation schema Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../modules/bookings/components/EventMeta.tsx | 1 + .../components/event-meta/Details.tsx | 3 +- .../components/locations/HostLocations.tsx | 841 ++++++++++++++++++ .../components/tabs/setup/EventSetupTab.tsx | 95 +- apps/web/public/static/locales/en/common.json | 15 + .../test/lib/handleChildrenEventTypes.test.ts | 9 + packages/app-store/locations.ts | 17 + .../handleNewBooking/getEventTypesFromDB.ts | 11 + .../test/per-host-locations.test.ts | 776 ++++++++++++++++ .../lib/service/RegularBookingService.ts | 50 +- packages/features/bookings/types.ts | 1 + .../round-robin/lib/bookingLocationService.ts | 121 +++ .../roundRobinManualReassignment.ts | 1 + .../ee/round-robin/roundRobinReassignment.ts | 1 + .../features/eventtypes/lib/defaultEvents.ts | 1 + .../features/eventtypes/lib/getPublicEvent.ts | 1 + packages/features/eventtypes/lib/types.ts | 13 + .../repositories/eventTypeRepository.ts | 32 + .../repositories/HostLocationRepository.ts | 60 ++ .../host/repositories/HostRepository.ts | 131 +++ packages/lib/server/eventTypeSelect.ts | 1 + packages/lib/test/builder.ts | 1 + .../event-types/hooks/useEventTypeForm.ts | 1 + .../migration.sql | 33 + packages/prisma/schema.prisma | 36 +- .../lib/bookingScenario/bookingScenario.ts | 23 + .../routers/viewer/eventTypes/_router.ts | 30 + .../getHostsWithLocationOptions.handler.ts | 143 +++ .../getHostsWithLocationOptions.schema.ts | 9 + .../viewer/eventTypes/heavy/update.handler.ts | 126 ++- .../massApplyHostLocation.handler.ts | 73 ++ .../massApplyHostLocation.schema.ts | 11 + .../server/routers/viewer/eventTypes/types.ts | 26 + 33 files changed, 2625 insertions(+), 68 deletions(-) create mode 100644 apps/web/modules/event-types/components/locations/HostLocations.tsx create mode 100644 packages/features/bookings/lib/handleNewBooking/test/per-host-locations.test.ts create mode 100644 packages/features/host/repositories/HostLocationRepository.ts create mode 100644 packages/prisma/migrations/20260116145525_add_custom_host_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 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/bookings/components/EventMeta.tsx b/apps/web/modules/bookings/components/EventMeta.tsx index a838cf72b0..ed67cc3838 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/bookings/components/event-meta/Details.tsx b/apps/web/modules/bookings/components/event-meta/Details.tsx index 693d296256..03fdbea0db 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 new file mode 100644 index 0000000000..a1c7ab7e75 --- /dev/null +++ b/apps/web/modules/event-types/components/locations/HostLocations.tsx @@ -0,0 +1,841 @@ +"use client"; + +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"; +import { + defaultLocations, + 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 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, 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 "@calcom/features/eventtypes/lib/types"; +import type { TLocationOptions } from "./Locations"; + +type HostWithLocationOptions = { + userId: number; + name: string | null; + email: string; + avatarUrl: string | null; + defaultConferencingApp: { + appSlug?: string; + appLink?: string; + } | null; + location: { + id: string; + 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 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 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; + locationOption: LocationOption | null; + onSave: (inputValue: string) => void; + title: string; + saveButtonText: string; + initialValue?: string; +}; + +const LocationInputDialog = ({ + isOpen, + onClose, + locationOption, + onSave, + title, + saveButtonText, + initialValue = "", +}: LocationInputDialogProps) => { + const { t } = useLocale(); + const eventLocationType = locationOption ? getEventLocationType(locationOption.value) : null; + const [inputValue, setInputValue] = useState(initialValue); + + useEffect(() => { + if (isOpen) { + setInputValue(initialValue); + } + }, [isOpen, initialValue]); + + 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, + locationOptions, + onLocationChange, +}: { + host: Host; + hostData?: HostWithLocationOptions; + locationOptions: TLocationOptions; + onLocationChange: (userId: number, location: HostLocation | null) => void; +}) => { + const { t } = useLocale(); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [pendingLocationOption, setPendingLocationOption] = useState(null); + + const currentLocation = host.location; + + const selectedOption = useMemo(() => { + if (currentLocation) { + return getLocationFromOptions(currentLocation.type, locationOptions); + } + + if (hostData?.defaultConferencingApp?.appSlug) { + const locationType = getAppFromSlug(hostData.defaultConferencingApp.appSlug)?.appData?.location?.type; + if (locationType) { + return getLocationFromOptions(locationType, locationOptions); + } + } + + return getLocationFromOptions("integrations:daily", locationOptions); + }, [currentLocation, hostData, locationOptions]); + + const hasAppInstalled = useMemo(() => { + if (!currentLocation?.type) return true; + if (isStaticLocationType(currentLocation.type)) return true; + if (isCalVideoLocation(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 displayName = hostData?.name || `${t("user")} ${host.userId}`; + const avatarUrl = hostData?.avatarUrl || undefined; + + const currentLocationEventType = currentLocation ? getEventLocationType(currentLocation.type) : null; + const hasOrganizerInput = !!currentLocationEventType?.organizerInputType; + + const currentLocationValue = useMemo(() => { + if (!currentLocation || !currentLocationEventType) return ""; + if (currentLocationEventType.defaultValueVariable === "link") { + return currentLocation.link || ""; + } + if (currentLocationEventType.defaultValueVariable === "address") { + return currentLocation.address || ""; + } + if (currentLocationEventType.organizerInputType === "phone") { + return currentLocation.phoneNumber || ""; + } + return ""; + }, [currentLocation, currentLocationEventType]); + + const handleEditClick = () => { + if (selectedOption) { + setPendingLocationOption(selectedOption); + setIsDialogOpen(true); + } + }; + + const handleLocationSelect = (option: LocationOption | null) => { + if (!option) { + onLocationChange(host.userId, null); + return; + } + + const eventLocationType = getEventLocationType(option.value); + + if (eventLocationType?.organizerInputType) { + setPendingLocationOption(option); + setIsDialogOpen(true); + 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, + }); + }; + + const handleDialogSave = (inputValue: string) => { + if (!pendingLocationOption) return; + + const eventLocationType = getEventLocationType(pendingLocationOption.value); + const credential = hostData?.installedApps.find( + (app) => + app.appId === getAppSlugFromLocationType(pendingLocationOption.value) || + app.type === pendingLocationOption.value + ); + + const location: HostLocation = { + userId: host.userId, + eventTypeId: 0, + type: pendingLocationOption.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; + } + + onLocationChange(host.userId, location); + setPendingLocationOption(null); + }; + + const handleDialogClose = () => { + setIsDialogOpen(false); + setPendingLocationOption(null); + }; + + return ( + <> +
+ +
+
{displayName}
+ {hostData?.email &&
{hostData.email}
} +
+
+ {currentLocation && !hasAppInstalled && ( + + + {t("app_not_installed")} + + )} + ({ ...base, zIndex: 9999 }) as CSSObjectWithLabel, + }} + onChange={handleLocationSelect} + /> + {hasOrganizerInput && currentLocation && ( +
+
+ + + ); +}; + +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(() => { + 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; + 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 ( + !open && handleClose()}> + + +
+
+ +