430 lines
15 KiB
TypeScript
430 lines
15 KiB
TypeScript
import type { TFunction } from "next-i18next";
|
|
import { z } from "zod";
|
|
|
|
import { appStoreMetadata } from "@calcom/app-store/bookerAppsMetaData";
|
|
import logger from "@calcom/lib/logger";
|
|
import { BookingStatus } from "@calcom/prisma/enums";
|
|
import type { Ensure, Optional } from "@calcom/types/utils";
|
|
|
|
import type { EventLocationTypeFromAppMeta } from "../types/App";
|
|
|
|
export type DefaultEventLocationType = {
|
|
default: true;
|
|
type: DefaultEventLocationTypeEnum;
|
|
label: string;
|
|
messageForOrganizer: string;
|
|
category: "in person" | "conferencing" | "other" | "phone";
|
|
|
|
iconUrl: string;
|
|
urlRegExp?: string;
|
|
// HACK: `variable` and `defaultValueVariable` are required due to legacy reason where different locations were stored in different places.
|
|
variable:
|
|
| "locationType"
|
|
| "locationAddress"
|
|
| "address"
|
|
| "locationLink"
|
|
| "locationPhoneNumber"
|
|
| "phone"
|
|
| "hostDefault";
|
|
defaultValueVariable: "address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "hostDefault" | "phone";
|
|
} & (
|
|
| {
|
|
organizerInputType: "phone" | "text" | null;
|
|
organizerInputPlaceholder?: string | null;
|
|
attendeeInputType?: null;
|
|
attendeeInputPlaceholder?: null;
|
|
}
|
|
| {
|
|
attendeeInputType: "phone" | "attendeeAddress" | null;
|
|
attendeeInputPlaceholder: string;
|
|
organizerInputType?: null;
|
|
organizerInputPlaceholder?: null;
|
|
}
|
|
);
|
|
|
|
export type EventLocationTypeFromApp = Ensure<
|
|
EventLocationTypeFromAppMeta,
|
|
"defaultValueVariable" | "variable"
|
|
>;
|
|
|
|
export type EventLocationType = DefaultEventLocationType | EventLocationTypeFromApp;
|
|
|
|
export const DailyLocationType = "integrations:daily";
|
|
|
|
export const MeetLocationType = "integrations:google:meet";
|
|
|
|
/**
|
|
* This isn't an actual location app type. It is a special value that informs to use the Organizer's default conferencing app during booking
|
|
*/
|
|
export const OrganizerDefaultConferencingAppType = "conferencing";
|
|
|
|
export enum DefaultEventLocationTypeEnum {
|
|
/**
|
|
* Booker Address
|
|
*/
|
|
AttendeeInPerson = "attendeeInPerson",
|
|
/**
|
|
* Organizer Address
|
|
*/
|
|
InPerson = "inPerson",
|
|
/**
|
|
* Booker Phone
|
|
*/
|
|
Phone = "phone",
|
|
/**
|
|
* Organizer Phone
|
|
*/
|
|
UserPhone = "userPhone",
|
|
Link = "link",
|
|
// Same as `OrganizerDefaultConferencingAppType`
|
|
Conferencing = "conferencing",
|
|
}
|
|
|
|
export const defaultLocations: DefaultEventLocationType[] = [
|
|
{
|
|
default: true,
|
|
type: DefaultEventLocationTypeEnum.AttendeeInPerson,
|
|
label: "in_person_attendee_address",
|
|
variable: "address",
|
|
organizerInputType: null,
|
|
messageForOrganizer: "Cal will ask your invitee to enter an address before scheduling.",
|
|
attendeeInputType: "attendeeAddress",
|
|
attendeeInputPlaceholder: "enter_address",
|
|
defaultValueVariable: "attendeeAddress",
|
|
iconUrl: "/map-pin-dark.svg",
|
|
category: "in person",
|
|
},
|
|
{
|
|
default: true,
|
|
type: DefaultEventLocationTypeEnum.InPerson,
|
|
label: "in_person",
|
|
organizerInputType: "text",
|
|
messageForOrganizer: "Provide an Address or Place",
|
|
// HACK:
|
|
variable: "locationAddress",
|
|
defaultValueVariable: "address",
|
|
iconUrl: "/map-pin-dark.svg",
|
|
category: "in person",
|
|
},
|
|
{
|
|
default: true,
|
|
type: DefaultEventLocationTypeEnum.Conferencing,
|
|
iconUrl: "/link.svg",
|
|
organizerInputType: null,
|
|
label: "organizer_default_conferencing_app",
|
|
variable: "hostDefault",
|
|
defaultValueVariable: "hostDefault",
|
|
category: "conferencing",
|
|
messageForOrganizer: "",
|
|
},
|
|
{
|
|
default: true,
|
|
type: DefaultEventLocationTypeEnum.Link,
|
|
label: "link_meeting",
|
|
organizerInputType: "text",
|
|
variable: "locationLink",
|
|
messageForOrganizer: "Provide a Meeting Link",
|
|
defaultValueVariable: "link",
|
|
iconUrl: "/link.svg",
|
|
category: "other",
|
|
},
|
|
{
|
|
default: true,
|
|
type: DefaultEventLocationTypeEnum.Phone,
|
|
label: "attendee_phone_number",
|
|
variable: "phone",
|
|
organizerInputType: null,
|
|
attendeeInputType: "phone",
|
|
attendeeInputPlaceholder: `enter_phone_number`,
|
|
defaultValueVariable: "phone",
|
|
messageForOrganizer: "Cal will ask your invitee to enter a phone number before scheduling.",
|
|
// This isn't inputType phone because organizer doesn't need to provide it.
|
|
// inputType: "phone"
|
|
iconUrl: "/phone.svg",
|
|
category: "phone",
|
|
},
|
|
{
|
|
default: true,
|
|
type: DefaultEventLocationTypeEnum.UserPhone,
|
|
label: "organizer_phone_number",
|
|
messageForOrganizer: "Provide your phone number",
|
|
organizerInputType: "phone",
|
|
variable: "locationPhoneNumber",
|
|
defaultValueVariable: "hostPhoneNumber",
|
|
iconUrl: "/phone.svg",
|
|
category: "phone",
|
|
},
|
|
];
|
|
|
|
const translateAbleKeys = [
|
|
"in_person_attendee_address",
|
|
"in_person",
|
|
"attendee_phone_number",
|
|
"link_meeting",
|
|
"organizer_phone_number",
|
|
];
|
|
|
|
export type LocationObject = {
|
|
type: string;
|
|
address?: string;
|
|
displayLocationPublicly?: boolean;
|
|
credentialId?: number;
|
|
} & Partial<
|
|
Record<"address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "hostDefault" | "phone", string>
|
|
>;
|
|
|
|
// integrations:jitsi | 919999999999 | Delhi | https://manual.meeting.link | Around Video
|
|
export type BookingLocationValue = string;
|
|
|
|
export const AppStoreLocationType: Record<string, string> = {};
|
|
|
|
const locationsFromApps: EventLocationTypeFromApp[] = [];
|
|
|
|
for (const [appName, meta] of Object.entries(appStoreMetadata)) {
|
|
const location = meta.appData?.location;
|
|
if (location) {
|
|
// TODO: This template variable replacement should happen once during app-store:build.
|
|
for (const [key, value] of Object.entries(location)) {
|
|
if (typeof value === "string") {
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
location[key] = value.replace(/{SLUG}/g, meta.slug).replace(/{TITLE}/g, meta.name);
|
|
}
|
|
}
|
|
const newLocation = {
|
|
...location,
|
|
messageForOrganizer: location.messageForOrganizer || `Set ${location.label} link`,
|
|
iconUrl: meta.logo,
|
|
// For All event location apps, locationLink is where we store the input
|
|
// TODO: locationLink and link seems redundant. We can modify the code to keep just one of them.
|
|
variable: location.variable || "locationLink",
|
|
defaultValueVariable: location.defaultValueVariable || "link",
|
|
};
|
|
|
|
// Static links always require organizer to input
|
|
if (newLocation.linkType === "static") {
|
|
newLocation.organizerInputType = location.organizerInputType || "text";
|
|
if (newLocation.organizerInputPlaceholder?.match(/https?:\/\//)) {
|
|
// HACK: Translation ends up removing https? if it's in the beginning :(
|
|
newLocation.organizerInputPlaceholder = ` ${newLocation.organizerInputPlaceholder}`;
|
|
}
|
|
} else {
|
|
newLocation.organizerInputType = null;
|
|
}
|
|
|
|
AppStoreLocationType[appName] = newLocation.type;
|
|
|
|
locationsFromApps.push({
|
|
...newLocation,
|
|
});
|
|
}
|
|
}
|
|
|
|
const locationsTypes = [...defaultLocations, ...locationsFromApps];
|
|
export const getStaticLinkBasedLocation = (locationType: string) =>
|
|
locationsFromApps.find((l) => l.linkType === "static" && l.type === locationType);
|
|
|
|
export const getEventLocationTypeFromApp = (locationType: string) =>
|
|
locationsFromApps.find((l) => l.type === locationType);
|
|
|
|
export const getEventLocationType = (locationType: string | undefined | null) =>
|
|
locationsTypes.find((l) => l.type === locationType);
|
|
|
|
export const getEventLocationTypeFromValue = (value: string | undefined | null) => {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
return locationsTypes.find((l) => {
|
|
if (l.default || l.linkType == "dynamic" || !l.urlRegExp) {
|
|
return;
|
|
}
|
|
return new RegExp(l.urlRegExp).test(value);
|
|
});
|
|
};
|
|
|
|
export const guessEventLocationType = (locationTypeOrValue: string | undefined | null) =>
|
|
getEventLocationType(locationTypeOrValue) || getEventLocationTypeFromValue(locationTypeOrValue);
|
|
|
|
export const LocationType = { ...DefaultEventLocationTypeEnum, ...AppStoreLocationType };
|
|
|
|
type PrivacyFilteredLocationObject = Optional<LocationObject, "address" | "link">;
|
|
|
|
export const privacyFilteredLocations = (locations: LocationObject[]): PrivacyFilteredLocationObject[] => {
|
|
const locationsAfterPrivacyFilter = locations.map((location) => {
|
|
const eventLocationType = getEventLocationType(location.type);
|
|
if (!eventLocationType) {
|
|
logger.debug(`Couldn't find location type. App might be uninstalled: ${location.type} `);
|
|
}
|
|
// Filter out locations that are not to be displayed publicly
|
|
// Display if the location can be set to public - and also display all locations like google meet etc
|
|
if (location.displayLocationPublicly || !eventLocationType) {
|
|
return location;
|
|
} else {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { address: _1, link: _2, hostPhoneNumber: _3, ...privacyFilteredLocation } = location;
|
|
logger.debug("Applied Privacy Filter", location, privacyFilteredLocation);
|
|
return privacyFilteredLocation;
|
|
}
|
|
});
|
|
return locationsAfterPrivacyFilter;
|
|
};
|
|
|
|
/**
|
|
* Use this function for translating event location to a readable string
|
|
* @param location
|
|
* @param t
|
|
* @returns string
|
|
*/
|
|
export const getMessageForOrganizer = (location: string, t: TFunction) => {
|
|
const videoLocation = getEventLocationTypeFromApp(location);
|
|
const defaultLocation = defaultLocations.find((l) => l.type === location);
|
|
if (defaultLocation) {
|
|
return t(defaultLocation.messageForOrganizer);
|
|
}
|
|
if (videoLocation && videoLocation.linkType !== "static" && videoLocation.type !== "integrations:zoom") {
|
|
return t(`Cal will provide a ${videoLocation.label} URL.`);
|
|
}
|
|
return "";
|
|
};
|
|
|
|
/**
|
|
* Use this function to translate booking location value to a readable string
|
|
* @param linkValue
|
|
* @param translationFunction
|
|
* @returns
|
|
*/
|
|
export const getHumanReadableLocationValue = (
|
|
linkValue: string | undefined | null,
|
|
translationFunction: TFunction
|
|
): string => {
|
|
if (!linkValue) {
|
|
return translationFunction("no_location");
|
|
}
|
|
|
|
// Just in case linkValue is a `locationType.type`(for old bookings)
|
|
const eventLocationType = getEventLocationType(linkValue);
|
|
const isDefault = eventLocationType?.default;
|
|
if (eventLocationType) {
|
|
// If we can find a video location based on linkValue then it means that the linkValue is something like integrations:google-meet and in that case we don't have the meeting URL to show.
|
|
// Show a generic message in that case.
|
|
return isDefault ? translationFunction(eventLocationType.label) : `${eventLocationType.label}`;
|
|
}
|
|
// Otherwise just show the available link value.
|
|
return linkValue || "";
|
|
};
|
|
|
|
export const locationKeyToString = (location: LocationObject) => {
|
|
const eventLocationType = getEventLocationType(location.type);
|
|
if (!eventLocationType) {
|
|
return null;
|
|
}
|
|
const defaultValueVariable = eventLocationType.defaultValueVariable;
|
|
if (!defaultValueVariable) {
|
|
console.error(`defaultValueVariable not set for ${location.type}`);
|
|
return "";
|
|
}
|
|
return location[defaultValueVariable] || eventLocationType.label;
|
|
};
|
|
|
|
export const getEventLocationWithType = (
|
|
locations: LocationObject[],
|
|
locationType: EventLocationType["type"] | undefined
|
|
) => {
|
|
const location = locations.find((location) => location.type === locationType);
|
|
return location;
|
|
};
|
|
|
|
/**
|
|
* It converts a static link based video location type(e.g. integrations:campfire_video) to it's value (e.g. https://campfire.to/my_link) set in the eventType.
|
|
* If the type provided is already a value(when displayLocationPublicly is on), it would just return that.
|
|
* For, dynamic link based video location apps, it doesn't do anything.
|
|
*/
|
|
export const getLocationValueForDB = (
|
|
bookingLocationTypeOrValue: EventLocationType["type"],
|
|
eventLocations: LocationObject[]
|
|
) => {
|
|
let bookingLocation = bookingLocationTypeOrValue;
|
|
let conferenceCredentialId = undefined;
|
|
eventLocations.forEach((location) => {
|
|
if (location.type === bookingLocationTypeOrValue) {
|
|
const eventLocationType = getEventLocationType(bookingLocationTypeOrValue);
|
|
conferenceCredentialId = location.credentialId;
|
|
if (!eventLocationType) {
|
|
return;
|
|
}
|
|
if (!eventLocationType.default && eventLocationType.linkType === "dynamic") {
|
|
// Dynamic link based locations should still be saved as type. The beyond logic generates meeting URL based on the type.
|
|
// This difference can be avoided when we start storing both type and value of a location
|
|
return;
|
|
}
|
|
|
|
bookingLocation = location[eventLocationType.defaultValueVariable] || bookingLocation;
|
|
}
|
|
});
|
|
return { bookingLocation, conferenceCredentialId };
|
|
};
|
|
|
|
export const getEventLocationValue = (eventLocations: LocationObject[], bookingLocation: LocationObject) => {
|
|
const eventLocationType = getEventLocationType(bookingLocation?.type);
|
|
if (!eventLocationType) {
|
|
return "";
|
|
}
|
|
const defaultValueVariable = eventLocationType.defaultValueVariable;
|
|
if (!defaultValueVariable) {
|
|
console.error(`${defaultValueVariable} not set for ${bookingLocation.type}`);
|
|
return "";
|
|
}
|
|
const eventLocation = getEventLocationWithType(eventLocations, bookingLocation?.type);
|
|
if (!eventLocation) {
|
|
console.error(`Could not find eventLocation for ${bookingLocation}`);
|
|
return "";
|
|
}
|
|
|
|
// Must send .type here if value isn't available due to privacy setting.
|
|
// For Booker Phone Number, it would be a value always. For others, value is either autogenerated or provided by Organizer and thus it's possible that organizer doesn't want it to show
|
|
// Backend checks for `integration` to generate link
|
|
// TODO: use zodSchema to ensure the type of data is correct
|
|
return (
|
|
bookingLocation[defaultValueVariable] || eventLocation[defaultValueVariable] || eventLocationType.type
|
|
);
|
|
};
|
|
|
|
export function getSuccessPageLocationMessage(
|
|
location: EventLocationType["type"],
|
|
t: TFunction,
|
|
bookingStatus?: BookingStatus
|
|
) {
|
|
const eventLocationType = getEventLocationType(location);
|
|
let locationToDisplay = location;
|
|
if (eventLocationType && !eventLocationType.default && eventLocationType.linkType === "dynamic") {
|
|
const isConfirmed = bookingStatus === BookingStatus.ACCEPTED;
|
|
|
|
if (bookingStatus === BookingStatus.CANCELLED || bookingStatus === BookingStatus.REJECTED) {
|
|
locationToDisplay == t("web_conference");
|
|
} else if (isConfirmed) {
|
|
locationToDisplay = `${getHumanReadableLocationValue(location, t)}: ${t(
|
|
"meeting_url_in_confirmation_email"
|
|
)}`;
|
|
} else {
|
|
locationToDisplay = t("web_conferencing_details_to_follow");
|
|
}
|
|
}
|
|
return locationToDisplay;
|
|
}
|
|
|
|
export const getTranslatedLocation = (
|
|
location: PrivacyFilteredLocationObject,
|
|
eventLocationType: ReturnType<typeof getEventLocationType>,
|
|
t: TFunction
|
|
) => {
|
|
if (!eventLocationType) return null;
|
|
const locationKey = z.string().default("").parse(locationKeyToString(location));
|
|
const translatedLocation = location.type.startsWith("integrations:")
|
|
? eventLocationType.label
|
|
: translateAbleKeys.includes(locationKey)
|
|
? t(locationKey)
|
|
: locationKey;
|
|
|
|
return translatedLocation;
|
|
};
|