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>
This commit is contained in:
co-authored by
unknown <>
unknown <>
unknown <>
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
b9b7e50f5a
commit
0ad4367005
@@ -80,6 +80,7 @@ export const EventMeta = ({
|
||||
| "isDynamic"
|
||||
| "fieldTranslations"
|
||||
| "autoTranslateDescriptionEnabled"
|
||||
| "enablePerHostLocations"
|
||||
> | null;
|
||||
isPending: boolean;
|
||||
isPrivateLink: boolean;
|
||||
|
||||
@@ -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 (
|
||||
<EventMetaBlock key={block}>
|
||||
<AvailableEventLocations locations={event.locations} />
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader title={t(title)} />
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-default mb-1 block text-sm font-medium">
|
||||
{t(eventLocationType.messageForOrganizer || "")}
|
||||
</Label>
|
||||
{eventLocationType.organizerInputType === "phone" ? (
|
||||
<PhoneInput
|
||||
value={inputValue}
|
||||
onChange={(val) => setInputValue(val || "")}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
type="text"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={handleClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSave} disabled={!inputValue}>
|
||||
{t(saveButtonText)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
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<LocationOption | null>(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 (
|
||||
<>
|
||||
<div className="border-subtle flex items-center gap-3 border-b px-3 py-3 last:border-b-0">
|
||||
<Avatar size="sm" imageSrc={avatarUrl} alt={displayName} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-emphasis truncate text-sm font-medium">{displayName}</div>
|
||||
{hostData?.email && <div className="text-subtle truncate text-xs">{hostData.email}</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentLocation && !hasAppInstalled && (
|
||||
<Badge variant="orange" className="whitespace-nowrap">
|
||||
<Icon name="triangle-alert" className="mr-1 h-3 w-3" />
|
||||
{t("app_not_installed")}
|
||||
</Badge>
|
||||
)}
|
||||
<LocationSelect
|
||||
placeholder={t("select_location")}
|
||||
options={locationOptions}
|
||||
value={selectedOption}
|
||||
isSearchable={false}
|
||||
className="w-72 text-sm"
|
||||
menuPlacement="auto"
|
||||
menuPortalTarget={typeof document !== "undefined" ? document.body : null}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }) as CSSObjectWithLabel,
|
||||
}}
|
||||
onChange={handleLocationSelect}
|
||||
/>
|
||||
{hasOrganizerInput && currentLocation && (
|
||||
<Button color="minimal" type="button" StartIcon="pencil" onClick={handleEditClick} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<LocationInputDialog
|
||||
isOpen={isDialogOpen}
|
||||
onClose={handleDialogClose}
|
||||
locationOption={pendingLocationOption}
|
||||
onSave={handleDialogSave}
|
||||
title="set_location"
|
||||
saveButtonText="save"
|
||||
initialValue={
|
||||
pendingLocationOption && pendingLocationOption.value !== currentLocation?.type
|
||||
? ""
|
||||
: currentLocationValue
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type AllLocationOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
const getAllLocationOptions = (): AllLocationOption[] => {
|
||||
const options: AllLocationOption[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
|
||||
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<string | null>(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 }) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{icon && <img src={icon} alt="" className={`h-4 w-4 ${invertLogoOnDark(icon)}`} />}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader title={t("apply_location_to_all_hosts")} />
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-1">{t("select_location_type")}</Label>
|
||||
<Select
|
||||
value={selectValue}
|
||||
onChange={(option) => {
|
||||
setSelectedType(option?.value || null);
|
||||
setInputValue("");
|
||||
}}
|
||||
options={selectOptions}
|
||||
className="w-full"
|
||||
components={{
|
||||
Option: (props) => (
|
||||
<components.Option {...props}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
</components.Option>
|
||||
),
|
||||
SingleValue: (props) => (
|
||||
<components.SingleValue {...props}>
|
||||
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
|
||||
</components.SingleValue>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{needsInput && eventLocationType && (
|
||||
<LocationInputField
|
||||
eventLocationType={eventLocationType}
|
||||
inputValue={inputValue}
|
||||
setInputValue={setInputValue}
|
||||
/>
|
||||
)}
|
||||
<Alert severity="info" title={t("mass_apply_fallback_explanation")} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={handleClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
disabled={!selectedType || (needsInput && !inputValue) || isApplying}
|
||||
loading={isApplying}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
type LocationInputFieldProps = {
|
||||
eventLocationType: ReturnType<typeof getEventLocationType>;
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
};
|
||||
|
||||
const LocationInputField = ({ eventLocationType, inputValue, setInputValue }: LocationInputFieldProps) => {
|
||||
const { t } = useLocale();
|
||||
if (!eventLocationType) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label className="mb-1">{t(eventLocationType.messageForOrganizer || "")}</Label>
|
||||
{eventLocationType.organizerInputType === "phone" ? (
|
||||
<PhoneInput
|
||||
value={inputValue}
|
||||
onChange={(val) => setInputValue(val || "")}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
type="text"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useFetchMoreOnScroll = (
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
hasNextPage: boolean | undefined,
|
||||
isFetchingNextPage: boolean,
|
||||
fetchNextPage: () => void
|
||||
) => {
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !hasNextPage || isFetchingNextPage) return;
|
||||
|
||||
const { scrollHeight, scrollTop, clientHeight } = container;
|
||||
if (scrollHeight - scrollTop - clientHeight < 100) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [containerRef, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener("scroll", handleScroll);
|
||||
return () => container.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll, containerRef]);
|
||||
};
|
||||
|
||||
const mergeLocationOptionsWithHostApps = (
|
||||
locationOptions: TLocationOptions,
|
||||
hostsWithApps: HostWithLocationOptions[],
|
||||
t: (key: string) => string
|
||||
): TLocationOptions => {
|
||||
if (hostsWithApps.length === 0) return locationOptions;
|
||||
|
||||
const existingValues = new Set<string>();
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
return [...locationOptions, { label: t("conferencing"), options: hostAppsOptions }];
|
||||
};
|
||||
|
||||
type HostListProps = {
|
||||
hosts: Host[];
|
||||
hostDataMap: Map<number, HostWithLocationOptions>;
|
||||
locationOptions: TLocationOptions;
|
||||
onLocationChange: (userId: number, location: HostLocation | null) => void;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
isLoading: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onOpenMassApply: () => void;
|
||||
};
|
||||
|
||||
const HostList = ({
|
||||
hosts,
|
||||
hostDataMap,
|
||||
locationOptions,
|
||||
onLocationChange,
|
||||
containerRef,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
onOpenMassApply,
|
||||
}: HostListProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton as={Label} loadingClassName="w-24" className="mt-auto mb-0">
|
||||
{t("host_locations")}
|
||||
</Skeleton>
|
||||
<Button type="button" color="secondary" onClick={onOpenMassApply}>
|
||||
{t("set_location_for_all_hosts")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="border-subtle max-h-96 overflow-y-auto rounded-md border">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Icon name="loader" className="text-subtle h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{hosts.map((host) => (
|
||||
<HostLocationRow
|
||||
key={host.userId}
|
||||
host={host}
|
||||
hostData={hostDataMap.get(host.userId)}
|
||||
locationOptions={locationOptions}
|
||||
onLocationChange={onLocationChange}
|
||||
/>
|
||||
))}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Icon name="loader" className="text-subtle h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-subtle text-xs">{t("host_locations_fallback_description")}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useHostLocationsData = (eventTypeId: number, enabled: boolean, locationOptions: TLocationOptions) => {
|
||||
const { t } = useLocale();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
|
||||
trpc.viewer.eventTypes.getHostsWithLocationOptions.useInfiniteQuery(
|
||||
{ eventTypeId, limit: 10 },
|
||||
{ enabled: enabled && eventTypeId > 0, getNextPageParam: (lastPage) => lastPage.nextCursor }
|
||||
);
|
||||
|
||||
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(() => {
|
||||
const merged = mergeLocationOptionsWithHostApps(locationOptions, hostsWithApps, t);
|
||||
return filterOutBookerInputLocations(merged);
|
||||
}, [locationOptions, hostsWithApps, t]);
|
||||
|
||||
useFetchMoreOnScroll(containerRef, hasNextPage, isFetchingNextPage, fetchNextPage);
|
||||
|
||||
return { hostDataMap, mergedLocationOptions, containerRef, isLoading, isFetchingNextPage };
|
||||
};
|
||||
|
||||
const useHostLocationHandlers = (
|
||||
formMethods: ReturnType<typeof useFormContext<FormValues>>,
|
||||
hosts: Host[]
|
||||
) => {
|
||||
const handleToggle = (checked: boolean) => {
|
||||
formMethods.setValue("enablePerHostLocations", checked, { shouldDirty: true });
|
||||
if (!checked) {
|
||||
formMethods.setValue(
|
||||
"hosts",
|
||||
hosts.map((host) => ({ ...host, location: null })),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocationChange = (userId: number, location: HostLocation | null) => {
|
||||
formMethods.setValue(
|
||||
"hosts",
|
||||
hosts.map((h) => (h.userId === userId ? { ...h, location } : h)),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
};
|
||||
|
||||
return { handleToggle, handleLocationChange };
|
||||
};
|
||||
|
||||
const useMassApplyMutation = (
|
||||
eventTypeId: number,
|
||||
formMethods: ReturnType<typeof useFormContext<FormValues>>,
|
||||
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 { handleMassApply, isPending: mutation.isPending };
|
||||
};
|
||||
|
||||
const UpgradeBadge = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<a href="/enterprise" className="hover:underline">
|
||||
<Badge variant="gray">{t("upgrade")}</Badge>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export const HostLocations = ({ eventTypeId, locationOptions }: HostLocationsProps) => {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const [isMassApplyDialogOpen, setIsMassApplyDialogOpen] = useState(false);
|
||||
|
||||
const isOrg = !!session.data?.user?.org?.id;
|
||||
const enablePerHostLocations = formMethods.watch("enablePerHostLocations");
|
||||
const hosts = formMethods.watch("hosts");
|
||||
|
||||
const { hostDataMap, mergedLocationOptions, containerRef, 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;
|
||||
|
||||
return (
|
||||
<div className="border-subtle rounded-lg border p-6">
|
||||
<div className="space-y-4">
|
||||
<SettingsToggle
|
||||
title={t("enable_custom_host_locations")}
|
||||
description={t("enable_custom_host_locations_description")}
|
||||
checked={enablePerHostLocations}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={!isOrg}
|
||||
Badge={!isOrg ? <UpgradeBadge /> : undefined}
|
||||
/>
|
||||
{enablePerHostLocations && (
|
||||
<HostList
|
||||
hosts={hosts}
|
||||
hostDataMap={hostDataMap}
|
||||
locationOptions={mergedLocationOptions}
|
||||
onLocationChange={handleLocationChange}
|
||||
containerRef={containerRef}
|
||||
isLoading={isLoading}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onOpenMassApply={() => setIsMassApplyDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<MassApplyLocationDialog
|
||||
isOpen={isMassApplyDialogOpen}
|
||||
onClose={() => setIsMassApplyDialogOpen(false)}
|
||||
onApply={handleMassApply}
|
||||
isApplying={isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HostLocations;
|
||||
@@ -13,10 +13,14 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { slugify } from "@calcom/lib/slugify";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Editor } from "@calcom/ui/components/editor";
|
||||
import { CheckboxField, Label, Select, SettingsToggle, TextAreaField, TextField } 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";
|
||||
import { useState } from "react";
|
||||
import type { Control, FormState, UseFormGetValues, UseFormSetValue } from "react-hook-form";
|
||||
@@ -73,6 +77,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, 40, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
|
||||
@@ -349,43 +354,61 @@ export const EventSetupTab = (
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-lg border border-subtle p-6",
|
||||
customClassNames?.locationSection?.container
|
||||
)}>
|
||||
<div>
|
||||
<Skeleton
|
||||
as={Label}
|
||||
loadingClassName="w-16"
|
||||
htmlFor="locations"
|
||||
className={customClassNames?.locationSection?.label}>
|
||||
{t("location")}
|
||||
{/*improve shouldLockIndicator function to also accept eventType and then conditionally render
|
||||
based on Managed Event type or not.*/}
|
||||
{shouldLockIndicator("locations")}
|
||||
</Skeleton>
|
||||
<Controller
|
||||
name="locations"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.locations || []}
|
||||
render={() => (
|
||||
<Locations
|
||||
showAppStoreLink={true}
|
||||
isChildrenManagedEventType={isChildrenManagedEventType}
|
||||
isManagedEventType={isManagedEventType}
|
||||
disableLocationProp={shouldLockDisableProps("locations").disabled}
|
||||
getValues={formMethods.getValues as unknown as UseFormGetValues<LocationFormValues>}
|
||||
setValue={formMethods.setValue as unknown as UseFormSetValue<LocationFormValues>}
|
||||
control={formMethods.control as unknown as Control<LocationFormValues>}
|
||||
formState={formMethods.formState as unknown as FormState<LocationFormValues>}
|
||||
{...props}
|
||||
customClassNames={customClassNames?.locationSection}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Tooltip
|
||||
content={t("locations_disabled_per_host_enabled")}
|
||||
side="top"
|
||||
open={
|
||||
eventType.schedulingType === SchedulingType.ROUND_ROBIN && enablePerHostLocations
|
||||
? undefined
|
||||
: false
|
||||
}>
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-lg border border-subtle p-6",
|
||||
customClassNames?.locationSection?.container,
|
||||
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
|
||||
enablePerHostLocations &&
|
||||
"cursor-not-allowed opacity-60"
|
||||
)}>
|
||||
<div>
|
||||
<Skeleton
|
||||
as={Label}
|
||||
loadingClassName="w-16"
|
||||
htmlFor="locations"
|
||||
className={customClassNames?.locationSection?.label}>
|
||||
{t("location")}
|
||||
{/*improve shouldLockIndicator function to also accept eventType and then conditionally render
|
||||
based on Managed Event type or not.*/}
|
||||
{shouldLockIndicator("locations")}
|
||||
</Skeleton>
|
||||
<Controller
|
||||
name="locations"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.locations || []}
|
||||
render={() => (
|
||||
<Locations
|
||||
showAppStoreLink={true}
|
||||
isChildrenManagedEventType={isChildrenManagedEventType}
|
||||
isManagedEventType={isManagedEventType}
|
||||
disableLocationProp={
|
||||
shouldLockDisableProps("locations").disabled ||
|
||||
(eventType.schedulingType === SchedulingType.ROUND_ROBIN && enablePerHostLocations)
|
||||
}
|
||||
getValues={formMethods.getValues as unknown as UseFormGetValues<LocationFormValues>}
|
||||
setValue={formMethods.setValue as unknown as UseFormSetValue<LocationFormValues>}
|
||||
control={formMethods.control as unknown as Control<LocationFormValues>}
|
||||
formState={formMethods.formState as unknown as FormState<LocationFormValues>}
|
||||
{...props}
|
||||
customClassNames={customClassNames?.locationSection}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && (
|
||||
<HostLocations eventTypeId={eventType.id} locationOptions={props.locationOptions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"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",
|
||||
@@ -4369,6 +4370,20 @@
|
||||
"saved": "Saved",
|
||||
"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 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 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",
|
||||
"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_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}}",
|
||||
|
||||
@@ -143,6 +143,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
enablePerHostLocations,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
id: 123,
|
||||
@@ -169,6 +170,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
enablePerHostLocations,
|
||||
};
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
|
||||
|
||||
@@ -230,6 +232,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
enablePerHostLocations,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -356,6 +359,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
enablePerHostLocations,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
id: 123,
|
||||
@@ -383,6 +387,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
enablePerHostLocations,
|
||||
};
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
|
||||
|
||||
@@ -446,6 +451,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
assignRRMembersUsingSegment,
|
||||
rrSegmentQueryValue,
|
||||
useEventLevelSelectedCalendars,
|
||||
enablePerHostLocations,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -520,6 +526,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
enablePerHostLocations,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -554,6 +561,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
enablePerHostLocations,
|
||||
};
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
|
||||
|
||||
@@ -579,6 +587,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
instantMeetingScheduleId: null,
|
||||
assignRRMembersUsingSegment: false,
|
||||
includeNoShowInRRCalculation: false,
|
||||
enablePerHostLocations,
|
||||
...evType,
|
||||
};
|
||||
|
||||
|
||||
@@ -301,6 +301,23 @@ 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 => {
|
||||
for (const [, meta] of Object.entries(appStoreMetadata)) {
|
||||
if (meta.appData?.location?.type === locationType) {
|
||||
return meta.slug;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type PrivacyFilteredLocationObject = Optional<LocationObject, "address" | "link" | "customLabel">;
|
||||
|
||||
export const privacyFilteredLocations = (locations: LocationObject[]): PrivacyFilteredLocationObject[] => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -0,0 +1,776 @@
|
||||
import {
|
||||
getBooker,
|
||||
TestData,
|
||||
getOrganizer,
|
||||
createBookingScenario,
|
||||
Timezones,
|
||||
getScenarioData,
|
||||
mockSuccessfulVideoMeetingCreation,
|
||||
BookingLocations,
|
||||
getDate,
|
||||
getGoogleCalendarCredential,
|
||||
getZoomAppCredential,
|
||||
mockCalendarToHaveNoBusySlots,
|
||||
} from "@calcom/testing/lib/bookingScenario/bookingScenario";
|
||||
import { expectBookingToBeInDatabase } from "@calcom/testing/lib/bookingScenario/expects";
|
||||
import { getMockRequestDataForBooking } from "@calcom/testing/lib/bookingScenario/getMockRequestDataForBooking";
|
||||
import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown";
|
||||
|
||||
import { describe, test, expect } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import { getNewBookingHandler } from "./getNewBookingHandler";
|
||||
|
||||
describe("Per-Host Locations - handleNewBooking", () => {
|
||||
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,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
credentialId: true,
|
||||
},
|
||||
});
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,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";
|
||||
@@ -1299,9 +1300,40 @@ 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 &&
|
||||
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
|
||||
organizerUser
|
||||
) {
|
||||
const organizerHost = eventType.hosts.find((host) => host.user.id === organizerUser.id);
|
||||
if (organizerHost?.location) {
|
||||
const result = await BookingLocationService.getPerHostLocation({
|
||||
hostLocation: organizerHost.location,
|
||||
allCredentials,
|
||||
eventTypeId: eventType.id,
|
||||
userId: organizerUser.id,
|
||||
prismaClient: deps.prismaClient,
|
||||
});
|
||||
|
||||
locationBodyString = result.locationBodyString;
|
||||
organizerOrFirstDynamicGroupMemberDefaultLocationUrl = result.organizerDefaultLocationUrl;
|
||||
perHostCredentialId = result.perHostCredentialId;
|
||||
|
||||
tracingLogger.info("Using per-host location", {
|
||||
userId: organizerUser.id,
|
||||
locationType: result.locationBodyString,
|
||||
credentialId: result.perHostCredentialId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (locationBodyString.trim().length === 0) {
|
||||
if (eventType.locations.length > 0) {
|
||||
locationBodyString = eventType.locations[0].type;
|
||||
} else {
|
||||
@@ -1396,12 +1428,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);
|
||||
|
||||
@@ -41,6 +41,7 @@ export type BookerEvent = Pick<
|
||||
| "recurringEvent"
|
||||
| "entity"
|
||||
| "locations"
|
||||
| "enablePerHostLocations"
|
||||
| "metadata"
|
||||
| "isDynamic"
|
||||
| "requiresConfirmation"
|
||||
|
||||
@@ -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<GetPerHostLocationResult> {
|
||||
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.link,
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ export const roundRobinManualReassignment = async ({
|
||||
schedule: null,
|
||||
createdAt: new Date(0), // use earliest possible date as fallback
|
||||
groupId: null,
|
||||
location: null,
|
||||
}));
|
||||
|
||||
const fixedHost = eventTypeHosts.find((host) => host.isFixed);
|
||||
|
||||
@@ -113,6 +113,7 @@ export const roundRobinReassignment = async ({
|
||||
schedule: null,
|
||||
createdAt: new Date(0), // use earliest possible date as fallback
|
||||
groupId: null,
|
||||
location: null,
|
||||
}));
|
||||
|
||||
if (eventType.hosts.length === 0) {
|
||||
|
||||
@@ -154,6 +154,7 @@ const commons = {
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
rrHostSubsetEnabled: false,
|
||||
enablePerHostLocations: false,
|
||||
};
|
||||
|
||||
export const dynamicEvent = {
|
||||
|
||||
@@ -70,6 +70,7 @@ export const getPublicEventSelect = (fetchAllUsers: boolean) => {
|
||||
schedulingType: true,
|
||||
length: true,
|
||||
locations: true,
|
||||
enablePerHostLocations: true,
|
||||
customInputs: true,
|
||||
disableGuests: true,
|
||||
metadata: true,
|
||||
|
||||
@@ -27,6 +27,17 @@ export type AvailabilityOption = {
|
||||
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?: string;
|
||||
userId: number;
|
||||
eventTypeId: number;
|
||||
type: EventLocationType["type"];
|
||||
credentialId?: number | null;
|
||||
link?: string | null;
|
||||
address?: string | null;
|
||||
phoneNumber?: string | null;
|
||||
};
|
||||
|
||||
export type Host = {
|
||||
isFixed: boolean;
|
||||
userId: number;
|
||||
@@ -34,6 +45,7 @@ export type Host = {
|
||||
weight: number;
|
||||
scheduleId?: number | null;
|
||||
groupId: string | null;
|
||||
location?: HostLocation | null;
|
||||
};
|
||||
export type TeamMember = {
|
||||
value: string;
|
||||
@@ -177,6 +189,7 @@ export type FormValues = {
|
||||
restrictionScheduleName: string | null;
|
||||
calVideoSettings?: CalVideoSettings;
|
||||
maxActiveBookingPerBookerOfferReschedule: boolean;
|
||||
enablePerHostLocations: boolean;
|
||||
};
|
||||
|
||||
export type LocationFormValues = Pick<FormValues, "id" | "locations" | "bookingFields" | "seatsPerTimeSlot">;
|
||||
|
||||
@@ -713,6 +713,16 @@ export class EventTypeRepository implements IEventTypesRepository {
|
||||
weight: true,
|
||||
scheduleId: true,
|
||||
groupId: true,
|
||||
location: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
credentialId: true,
|
||||
link: true,
|
||||
address: true,
|
||||
phoneNumber: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
timeZone: true,
|
||||
@@ -720,6 +730,7 @@ export class EventTypeRepository implements IEventTypesRepository {
|
||||
},
|
||||
},
|
||||
},
|
||||
enablePerHostLocations: true,
|
||||
userId: true,
|
||||
price: true,
|
||||
children: {
|
||||
@@ -1013,6 +1024,16 @@ export class EventTypeRepository implements IEventTypesRepository {
|
||||
priority: true,
|
||||
weight: true,
|
||||
scheduleId: true,
|
||||
location: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
credentialId: true,
|
||||
link: true,
|
||||
address: true,
|
||||
phoneNumber: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
timeZone: true,
|
||||
@@ -1020,6 +1041,7 @@ export class EventTypeRepository implements IEventTypesRepository {
|
||||
},
|
||||
},
|
||||
},
|
||||
enablePerHostLocations: true,
|
||||
userId: true,
|
||||
price: true,
|
||||
children: {
|
||||
@@ -1589,6 +1611,16 @@ export class EventTypeRepository implements IEventTypesRepository {
|
||||
});
|
||||
}
|
||||
|
||||
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 },
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AppCategories } from "@calcom/prisma/enums";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { safeCredentialSelect } from "@calcom/prisma/selects/credential";
|
||||
|
||||
export class HostRepository {
|
||||
constructor(private prismaClient: PrismaClient) {}
|
||||
@@ -37,4 +39,133 @@ 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: safeCredentialSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
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: safeCredentialSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
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 };
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export const eventTypeSelect = {
|
||||
aiPhoneCallConfig: true,
|
||||
assignAllTeamMembers: true,
|
||||
isRRWeightsEnabled: true,
|
||||
enablePerHostLocations: true,
|
||||
rescheduleWithSameRoundRobinHost: true,
|
||||
recurringEvent: true,
|
||||
locations: true,
|
||||
|
||||
@@ -167,6 +167,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
rrHostSubsetEnabled: false,
|
||||
enablePerHostLocations: false,
|
||||
...eventType,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -147,6 +147,7 @@ export const useEventTypeForm = ({
|
||||
maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker || null,
|
||||
maxActiveBookingPerBookerOfferReschedule: eventType.maxActiveBookingPerBookerOfferReschedule,
|
||||
showOptimizedSlots: eventType.showOptimizedSlots ?? false,
|
||||
enablePerHostLocations: eventType.enablePerHostLocations ?? false,
|
||||
};
|
||||
}, [eventType, periodDates]);
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."EventType" ADD COLUMN "enablePerHostLocations" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."HostLocation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"eventTypeId" INTEGER NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"credentialId" INTEGER,
|
||||
"link" TEXT,
|
||||
"address" TEXT,
|
||||
"phoneNumber" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "HostLocation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "HostLocation_credentialId_idx" ON "public"."HostLocation"("credentialId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "HostLocation_eventTypeId_idx" ON "public"."HostLocation"("eventTypeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "HostLocation_userId_eventTypeId_key" ON "public"."HostLocation"("userId", "eventTypeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."HostLocation" ADD CONSTRAINT "HostLocation_userId_eventTypeId_fkey" FOREIGN KEY ("userId", "eventTypeId") REFERENCES "public"."Host"("userId", "eventTypeId") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."HostLocation" ADD CONSTRAINT "HostLocation_credentialId_fkey" FOREIGN KEY ("credentialId") REFERENCES "public"."Credential"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -59,22 +59,23 @@ enum CreationSource {
|
||||
}
|
||||
|
||||
model Host {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
|
||||
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
|
||||
eventTypeId Int
|
||||
isFixed Boolean @default(false)
|
||||
isFixed Boolean @default(false)
|
||||
priority Int?
|
||||
weight Int?
|
||||
// weightAdjustment is deprecated. We not calculate the calibratino value on the spot. Plan to drop this column.
|
||||
weightAdjustment Int?
|
||||
schedule Schedule? @relation(fields: [scheduleId], references: [id])
|
||||
schedule Schedule? @relation(fields: [scheduleId], references: [id])
|
||||
scheduleId Int?
|
||||
createdAt DateTime @default(now())
|
||||
group HostGroup? @relation(fields: [groupId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
group HostGroup? @relation(fields: [groupId], references: [id])
|
||||
groupId String?
|
||||
memberId Int?
|
||||
member Membership? @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
member Membership? @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
location HostLocation?
|
||||
|
||||
@@id([userId, eventTypeId])
|
||||
@@index([memberId])
|
||||
@@ -96,6 +97,25 @@ model HostGroup {
|
||||
@@index([eventTypeId])
|
||||
}
|
||||
|
||||
model HostLocation {
|
||||
id String @id @default(uuid())
|
||||
userId Int
|
||||
eventTypeId Int
|
||||
host Host @relation(fields: [userId, eventTypeId], references: [userId, eventTypeId], onDelete: Cascade)
|
||||
type String
|
||||
credentialId Int?
|
||||
credential Credential? @relation(fields: [credentialId], references: [id], onDelete: SetNull)
|
||||
link String?
|
||||
address String?
|
||||
phoneNumber String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, eventTypeId])
|
||||
@@index([credentialId])
|
||||
@@index([eventTypeId])
|
||||
}
|
||||
|
||||
model CalVideoSettings {
|
||||
eventTypeId Int @id
|
||||
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
|
||||
@@ -259,6 +279,7 @@ model EventType {
|
||||
|
||||
bookingRequiresAuthentication Boolean @default(false)
|
||||
rrHostSubsetEnabled Boolean @default(false)
|
||||
enablePerHostLocations Boolean @default(false)
|
||||
|
||||
createdAt DateTime? @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
@@ -304,6 +325,7 @@ model Credential {
|
||||
delegationCredentialId String?
|
||||
delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: Cascade)
|
||||
integrationAttributeSyncs IntegrationAttributeSync[]
|
||||
hostLocations HostLocation[]
|
||||
|
||||
@@index([appId])
|
||||
@@index([subscriptionId])
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { ZGetActiveOnOptionsSchema } from "./getActiveOnOptions.schema";
|
||||
import { ZEventTypeInputSchema, ZGetEventTypesFromGroupSchema } from "./getByViewer.schema";
|
||||
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";
|
||||
|
||||
@@ -145,4 +147,32 @@ export const eventTypesRouter = router({
|
||||
input,
|
||||
});
|
||||
}),
|
||||
|
||||
getHostsWithLocationOptions: createEventPbacProcedure("eventType.update", [
|
||||
MembershipRole.ADMIN,
|
||||
MembershipRole.OWNER,
|
||||
])
|
||||
.input(ZGetHostsWithLocationOptionsInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { getHostsWithLocationOptionsHandler } = await import("./getHostsWithLocationOptions.handler");
|
||||
|
||||
return getHostsWithLocationOptionsHandler({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
|
||||
massApplyHostLocation: createEventPbacProcedure("eventType.update", [
|
||||
MembershipRole.ADMIN,
|
||||
MembershipRole.OWNER,
|
||||
])
|
||||
.input(ZMassApplyHostLocationInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { massApplyHostLocationHandler } = await import("./massApplyHostLocation.handler");
|
||||
|
||||
return massApplyHostLocationHandler({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
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";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { TGetHostsWithLocationOptionsInputSchema } from "./getHostsWithLocationOptions.schema";
|
||||
|
||||
type GetHostsWithLocationOptionsInput = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
input: TGetHostsWithLocationOptionsInputSchema;
|
||||
};
|
||||
|
||||
type HostFromRepository = Awaited<
|
||||
ReturnType<HostRepository["findHostsWithLocationOptionsPaginated"]>
|
||||
>["items"][number];
|
||||
|
||||
type EnrichedCredential = {
|
||||
id: number;
|
||||
appId: string | null;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export 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;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
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.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;
|
||||
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<GetHostsWithLocationOptionsResponse> => {
|
||||
const { eventTypeId, cursor, limit } = input;
|
||||
|
||||
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 = ctx.user.organizationId ?? null;
|
||||
const hostRepository = new HostRepository(ctx.prisma);
|
||||
const {
|
||||
items: hosts,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
} = await hostRepository.findHostsWithLocationOptionsPaginated({
|
||||
eventTypeId,
|
||||
cursor,
|
||||
limit,
|
||||
});
|
||||
|
||||
const usersForEnrichment = hosts.map((host) => ({
|
||||
id: host.user.id,
|
||||
email: host.user.email,
|
||||
credentials: host.user.credentials.map((cred) => ({
|
||||
...cred,
|
||||
key: {} as Prisma.JsonValue,
|
||||
})),
|
||||
}));
|
||||
|
||||
const enrichedUsers = await enrichUsersWithDelegationCredentials({
|
||||
orgId: organizationId,
|
||||
users: usersForEnrichment,
|
||||
});
|
||||
|
||||
return {
|
||||
hosts: transformHostsToResponse(hosts, enrichedUsers),
|
||||
nextCursor,
|
||||
hasMore,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
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<typeof ZGetHostsWithLocationOptionsInputSchema>;
|
||||
@@ -102,6 +102,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
restrictionScheduleId,
|
||||
calVideoSettings,
|
||||
hostGroups,
|
||||
enablePerHostLocations,
|
||||
...rest
|
||||
} = input;
|
||||
|
||||
@@ -256,6 +257,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
}),
|
||||
seatsPerTimeSlot,
|
||||
maxLeadThreshold: isLoadBalancingDisabled ? null : rest.maxLeadThreshold,
|
||||
...(enablePerHostLocations !== undefined && { enablePerHostLocations }),
|
||||
};
|
||||
data.locations = locations ?? undefined;
|
||||
|
||||
@@ -473,6 +475,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 +493,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));
|
||||
|
||||
@@ -500,29 +507,105 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
})),
|
||||
},
|
||||
create: newHosts.map((host) => {
|
||||
return {
|
||||
...host,
|
||||
const hostData: {
|
||||
userId: number;
|
||||
isFixed: boolean;
|
||||
priority: number;
|
||||
weight: number;
|
||||
groupId: string | null | undefined;
|
||||
scheduleId?: number | null | undefined;
|
||||
location?: {
|
||||
create: {
|
||||
type: string;
|
||||
credentialId: number | null | undefined;
|
||||
link: string | null | undefined;
|
||||
address: string | null | undefined;
|
||||
phoneNumber: string | null | undefined;
|
||||
};
|
||||
};
|
||||
} = {
|
||||
userId: host.userId,
|
||||
isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed || false,
|
||||
priority: host.priority ?? 2,
|
||||
weight: host.weight ?? 100,
|
||||
groupId: host.groupId,
|
||||
scheduleId: host.scheduleId ?? null,
|
||||
};
|
||||
if (host.location) {
|
||||
hostData.location = {
|
||||
create: {
|
||||
type: host.location.type,
|
||||
credentialId: host.location.credentialId,
|
||||
link: host.location.link,
|
||||
address: host.location.address,
|
||||
phoneNumber: host.location.phoneNumber,
|
||||
},
|
||||
};
|
||||
}
|
||||
return hostData;
|
||||
}),
|
||||
update: existingHosts.map((host) => {
|
||||
const updateData: {
|
||||
isFixed: boolean | undefined;
|
||||
priority: number;
|
||||
weight: number;
|
||||
scheduleId: number | null | undefined;
|
||||
groupId: string | null | undefined;
|
||||
location?: {
|
||||
upsert: {
|
||||
create: {
|
||||
type: string;
|
||||
credentialId: number | null | undefined;
|
||||
link: string | null | undefined;
|
||||
address: string | null | undefined;
|
||||
phoneNumber: string | null | undefined;
|
||||
};
|
||||
update: {
|
||||
type: string;
|
||||
credentialId: number | null | undefined;
|
||||
link: string | null | undefined;
|
||||
address: string | null | undefined;
|
||||
phoneNumber: string | null | undefined;
|
||||
};
|
||||
};
|
||||
};
|
||||
} = {
|
||||
isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed,
|
||||
priority: host.priority ?? 2,
|
||||
weight: host.weight ?? 100,
|
||||
scheduleId: host.scheduleId === undefined ? undefined : host.scheduleId,
|
||||
groupId: host.groupId,
|
||||
};
|
||||
}),
|
||||
update: existingHosts.map((host) => ({
|
||||
where: {
|
||||
userId_eventTypeId: {
|
||||
userId: host.userId,
|
||||
eventTypeId: id,
|
||||
if (host.location) {
|
||||
updateData.location = {
|
||||
upsert: {
|
||||
create: {
|
||||
type: host.location.type,
|
||||
credentialId: host.location.credentialId,
|
||||
link: host.location.link,
|
||||
address: host.location.address,
|
||||
phoneNumber: host.location.phoneNumber,
|
||||
},
|
||||
update: {
|
||||
type: host.location.type,
|
||||
credentialId: host.location.credentialId,
|
||||
link: host.location.link,
|
||||
address: host.location.address,
|
||||
phoneNumber: host.location.phoneNumber,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
where: {
|
||||
userId_eventTypeId: {
|
||||
userId: host.userId,
|
||||
eventTypeId: id,
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed,
|
||||
priority: host.priority ?? 2,
|
||||
weight: host.weight ?? 100,
|
||||
scheduleId: host.scheduleId ?? null,
|
||||
groupId: host.groupId,
|
||||
},
|
||||
})),
|
||||
data: updateData,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -710,6 +793,15 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (hostLocationDeletions.length > 0) {
|
||||
await ctx.prisma.hostLocation.deleteMany({
|
||||
where: {
|
||||
OR: hostLocationDeletions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedValues = Object.entries(data).reduce((acc, [key, value]) => {
|
||||
if (value !== undefined) {
|
||||
// @ts-expect-error Element implicitly has any type
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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 { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { TMassApplyHostLocationInputSchema } from "./massApplyHostLocation.schema";
|
||||
|
||||
type MassApplyHostLocationInput = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
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<MassApplyHostLocationResponse> => {
|
||||
const { eventTypeId, locationType, link, address, phoneNumber } = input;
|
||||
|
||||
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 hostRepo = new HostRepository(ctx.prisma);
|
||||
const hosts = await hostRepo.findHostsWithConferencingCredentials(eventTypeId);
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return { success: true, updatedCount: 0 };
|
||||
}
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
await hostLocationRepo.upsertMany(locations);
|
||||
|
||||
return { success: true, updatedCount: hosts.length };
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZMassApplyHostLocationInputSchema = z.object({
|
||||
eventTypeId: z.number().int(),
|
||||
locationType: z.string(),
|
||||
link: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
phoneNumber: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TMassApplyHostLocationInputSchema = z.infer<typeof ZMassApplyHostLocationInputSchema>;
|
||||
@@ -59,6 +59,17 @@ type CalVideoSettings = {
|
||||
requireEmailForGuests?: boolean | null;
|
||||
} | null;
|
||||
|
||||
type HostLocationInput = {
|
||||
id?: string;
|
||||
userId: number;
|
||||
eventTypeId: number;
|
||||
type: string;
|
||||
credentialId?: number | null;
|
||||
link?: string | null;
|
||||
address?: string | null;
|
||||
phoneNumber?: string | null;
|
||||
};
|
||||
|
||||
type HostInput = {
|
||||
userId: number;
|
||||
profileId?: number | null;
|
||||
@@ -67,6 +78,7 @@ type HostInput = {
|
||||
weight?: number | null;
|
||||
scheduleId?: number | null;
|
||||
groupId?: string | null;
|
||||
location?: HostLocationInput | null;
|
||||
};
|
||||
|
||||
type HostGroupInput = {
|
||||
@@ -233,6 +245,7 @@ export type TUpdateInputSchema = {
|
||||
instantMeetingSchedule?: number | null;
|
||||
multiplePrivateLinks?: (string | HashedLinkInput)[];
|
||||
hostGroups?: HostGroupInput[];
|
||||
enablePerHostLocations?: boolean;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -276,6 +289,17 @@ const calVideoSettingsSchema: z.ZodType<CalVideoSettings | undefined> = z
|
||||
.optional()
|
||||
.nullable();
|
||||
|
||||
const hostLocationSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
userId: z.number(),
|
||||
eventTypeId: z.number(),
|
||||
type: z.string(),
|
||||
credentialId: z.number().optional().nullable(),
|
||||
link: z.string().optional().nullable(),
|
||||
address: z.string().optional().nullable(),
|
||||
phoneNumber: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
const hostSchema: z.ZodType<HostInput> = z.object({
|
||||
userId: z.number(),
|
||||
profileId: z.number().or(z.null()).optional(),
|
||||
@@ -284,6 +308,7 @@ const hostSchema: z.ZodType<HostInput> = z.object({
|
||||
weight: z.number().min(0).optional().nullable(),
|
||||
scheduleId: z.number().optional().nullable(),
|
||||
groupId: z.string().optional().nullable(),
|
||||
location: hostLocationSchema.optional().nullable(),
|
||||
});
|
||||
|
||||
const hostGroupSchema: z.ZodType<HostGroupInput> = z.object({
|
||||
@@ -417,6 +442,7 @@ const BaseEventTypeUpdateInput: z.ZodType<TUpdateInputSchema> = z
|
||||
instantMeetingSchedule: z.number().nullable().optional(),
|
||||
multiplePrivateLinks: z.array(z.union([z.string(), hashedLinkInputSchema])).optional(),
|
||||
hostGroups: z.array(hostGroupSchema).optional(),
|
||||
enablePerHostLocations: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user