fix: Location Change to Organizer Default Conferencing App (#16379)

* Fix

* Dont include test files

* Fix location change

* Self review fixes

* Add unit test

* Add more test

* Add a bookingScenario as well
This commit is contained in:
Hariom Balhara
2024-09-11 21:31:53 +09:00
committed by GitHub
parent 4d53f327d9
commit e362c37ff3
20 changed files with 1343 additions and 296 deletions
+2 -2
View File
@@ -2,7 +2,7 @@ import { useCallback, useState } from "react";
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
import { InstallAppButton } from "@calcom/app-store/components";
import { getEventLocationTypeFromApp, type EventLocationType } from "@calcom/app-store/locations";
import { getLocationFromApp, type EventLocationType } from "@calcom/app-store/locations";
import type { CredentialOwner } from "@calcom/app-store/types";
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
import { BulkEditDefaultForEventsModal } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
@@ -92,7 +92,7 @@ export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppL
color="secondary"
StartIcon="video"
onClick={() => {
const locationType = getEventLocationTypeFromApp(item?.locationOption?.value ?? "");
const locationType = getLocationFromApp(item?.locationOption?.value ?? "");
if (locationType?.linkType === "static") {
setLocationType({ ...locationType, slug: appSlug });
} else {
+29 -17
View File
@@ -2,12 +2,8 @@ import Link from "next/link";
import { useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import type { EventLocationType, getEventLocationValue } from "@calcom/app-store/locations";
import {
getEventLocationType,
getSuccessPageLocationMessage,
guessEventLocationType,
} from "@calcom/app-store/locations";
import type { getEventLocationValue } from "@calcom/app-store/locations";
import { getSuccessPageLocationMessage, guessEventLocationType } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
// TODO: Use browser locale, implement Intl in Dayjs maybe?
import "@calcom/dayjs/locales";
@@ -284,20 +280,36 @@ function BookingListItem(booking: BookingItemProps) {
setIsOpenLocationDialog(false);
utils.viewer.bookings.invalidate();
},
onError: (e) => {
const errorMessages: Record<string, string> = {
UNAUTHORIZED: t("you_are_unauthorized_to_make_this_change_to_the_booking"),
BAD_REQUEST: e.message,
};
const message = errorMessages[e.data?.code as string] || t("location_update_failed");
showToast(message, "error");
},
});
const saveLocation = (
newLocationType: EventLocationType["type"],
details: {
[key: string]: string;
const saveLocation = async ({
newLocation,
credentialId,
}: {
newLocation: string;
/**
* It could be set for conferencing locations that support team level installations.
*/
credentialId: number | null;
}) => {
try {
await setLocationMutation.mutateAsync({
bookingId: booking.id,
newLocation,
credentialId,
});
} catch {
// Errors are shown through the mutation onError handler
}
) => {
let newLocation = newLocationType as string;
const eventLocationType = getEventLocationType(newLocationType);
if (eventLocationType?.organizerInputType) {
newLocation = details[Object.keys(details)[0]];
}
setLocationMutation.mutate({ bookingId: booking.id, newLocation, details });
};
// Getting accepted recurring dates to show
+65 -117
View File
@@ -1,9 +1,7 @@
import { ErrorMessage } from "@hookform/error-message";
import { zodResolver } from "@hookform/resolvers/zod";
import { isValidPhoneNumber } from "libphonenumber-js";
import { Trans } from "next-i18next";
import Link from "next/link";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { Controller, useForm, useWatch, useFormContext } from "react-hook-form";
import { z } from "zod";
@@ -12,25 +10,30 @@ import {
getEventLocationType,
getHumanReadableLocationValue,
getMessageForOrganizer,
isAttendeeInputRequired,
LocationType,
OrganizerDefaultConferencingAppType,
} from "@calcom/app-store/locations";
import CheckboxField from "@calcom/features/form/components/CheckboxField";
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 type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Button, Icon, Input, Dialog, DialogContent, DialogFooter, Form, PhoneInput } from "@calcom/ui";
import { QueryCell } from "@lib/QueryCell";
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
import { QueryCell } from "../../lib/QueryCell";
interface ISetLocationDialog {
saveLocation: (newLocationType: EventLocationType["type"], details: { [key: string]: string }) => void;
saveLocation: ({
newLocation,
credentialId,
}: {
newLocation: string;
credentialId: number | null;
}) => Promise<void>;
selection?: LocationOption;
booking?: BookingItem;
booking: {
location: string | null;
};
defaultValues?: LocationObject[];
setShowLocationModal: React.Dispatch<React.SetStateAction<boolean>>;
isOpenDialog: boolean;
@@ -100,8 +103,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
locationType: z.string(),
phone: z.string().optional().nullable(),
locationAddress: z.string().optional(),
credentialId: z.number().optional(),
teamName: z.string().optional(),
credentialId: z.number().nullable().optional(),
locationLink: z
.string()
.optional()
@@ -134,7 +136,6 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
}
return;
}),
displayLocationPublicly: z.boolean().optional(),
locationPhoneNumber: z
.string()
.nullable()
@@ -145,6 +146,8 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
.optional(),
});
const [isLocationUpdating, setIsLocationUpdating] = useState(false);
const locationFormMethods = useForm({
mode: "onSubmit",
resolver: zodResolver(locationFormSchema),
@@ -172,7 +175,10 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
}
);
const LocationOptions = (() => {
/**
* Depending on the location type that is selected, we show different input types or no input at all.
*/
const SelectedLocationInput = (() => {
if (eventLocationType && eventLocationType.organizerInputType && LocationInput) {
if (!eventLocationType.variable) {
console.error("eventLocationType.variable can't be undefined");
@@ -203,25 +209,6 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
as="p"
/>
</div>
{!booking && (
<div className="mt-3">
<Controller
name="displayLocationPublicly"
control={locationFormMethods.control}
render={() => (
<CheckboxField
data-testid="display-location"
defaultChecked={defaultLocation?.displayLocationPublicly}
description={t("display_location_label")}
onChange={(e) =>
locationFormMethods.setValue("displayLocationPublicly", e.target.checked)
}
informationIconText={t("display_location_info_badge")}
/>
)}
/>
</div>
)}
</div>
);
} else {
@@ -241,91 +228,51 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
<h3 className="text-emphasis text-lg font-medium leading-6" id="modal-title">
{t("edit_location")}
</h3>
{!booking && (
<p className="text-default text-sm">
<Trans i18nKey="cant_find_the_right_conferencing_app_visit_our_app_store">
Can&apos;t find the right conferencing app? Visit our
<Link
className="cursor-pointer text-blue-500 underline"
href="/apps/categories/conferencing">
App Store
</Link>
.
</Trans>
</p>
)}
</div>
<div className="mt-3 text-center sm:mt-0 sm:text-left" />
{booking && (
<>
<p className="text-emphasis mb-2 ml-1 mt-6 text-sm font-bold">{t("current_location")}:</p>
<p className="text-emphasis mb-2 ml-1 text-sm">
{getHumanReadableLocationValue(booking.location, t)}
</p>
</>
)}
<p className="text-emphasis mb-2 ml-1 mt-6 text-sm font-bold">{t("current_location")}:</p>
<p className="text-emphasis mb-2 ml-1 text-sm">
{getHumanReadableLocationValue(booking.location, t)}
</p>
<Form
form={locationFormMethods}
handleSubmit={async (values) => {
const { locationType: newLocation, displayLocationPublicly } = values;
let details = {};
if (newLocation === LocationType.InPerson) {
details = {
address: values.locationAddress,
};
}
const eventLocationType = getEventLocationType(newLocation);
// TODO: There can be a property that tells if it is to be saved in `link`
if (
newLocation === LocationType.Link ||
(!eventLocationType?.default && eventLocationType?.linkType === "static")
) {
details = { link: values.locationLink };
}
if (newLocation === LocationType.UserPhone) {
details = { hostPhoneNumber: values.locationPhoneNumber };
}
const { locationType: newLocationType } = values;
let newLocation;
// For the locations that require organizer to type-in some values, we need the value
if (eventLocationType?.organizerInputType) {
details = {
...details,
displayLocationPublicly,
};
newLocation = values[eventLocationType.variable];
} else {
// locationType itself can be used here e.g. For zoom we use the type itself which is "integrations:zoom". For Organizer's Default Conferencing App, it is OrganizerDefaultConferencingAppType constant
newLocation = newLocationType;
}
if (values.credentialId) {
details = {
...details,
credentialId: values.credentialId,
};
setIsLocationUpdating(true);
try {
await saveLocation({
newLocation,
credentialId: values.credentialId ?? null,
});
setIsLocationUpdating(false);
setShowLocationModal(false);
setSelectedLocation?.(undefined);
locationFormMethods.unregister([
"locationType",
"locationLink",
"locationAddress",
"locationPhoneNumber",
]);
} catch (error) {
// Let the user retry
setIsLocationUpdating(false);
}
if (values.teamName) {
details = {
...details,
teamName: values.teamName,
};
}
saveLocation(newLocation, details);
setShowLocationModal(false);
setSelectedLocation?.(undefined);
locationFormMethods.unregister([
"locationType",
"locationLink",
"locationAddress",
"locationPhoneNumber",
]);
}}>
<QueryCell
query={locationsQuery}
success={({ data }) => {
if (!data.length) return null;
const locationOptions = [...data].map((option) => {
let locationOptions = [...data].map((option) => {
if (teamId) {
// Let host's Default conferencing App option show for Team Event
return option;
@@ -335,13 +282,11 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
options: option.options.filter((o) => o.value !== OrganizerDefaultConferencingAppType),
};
});
if (booking) {
locationOptions.map((location) =>
location.options.filter(
(l) => !["phone", "attendeeInPerson", "somewhereElse"].includes(l.value)
)
);
}
locationOptions = locationOptions.map((locationOption) =>
filterLocationOptionsForBooking(locationOption)
);
return (
<Controller
name="locationType"
@@ -357,11 +302,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
onChange={(val) => {
if (val) {
locationFormMethods.setValue("locationType", val.value);
if (typeof val.credentialId === "number" && val.credentialId >= 0) {
locationFormMethods.setValue("credentialId", val.credentialId);
locationFormMethods.setValue("teamName", val.teamName ?? "");
}
locationFormMethods.setValue("credentialId", val.credentialId);
locationFormMethods.unregister([
"locationLink",
"locationAddress",
@@ -382,7 +323,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
);
}}
/>
{selectedLocation && LocationOptions}
{selectedLocation && SelectedLocationInput}
<DialogFooter className="relative">
<Button
onClick={() => {
@@ -396,7 +337,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
{t("cancel")}
</Button>
<Button data-testid="update-location" type="submit">
<Button data-testid="update-location" type="submit" disabled={isLocationUpdating}>
{t("update")}
</Button>
</DialogFooter>
@@ -407,3 +348,10 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
</Dialog>
);
};
function filterLocationOptionsForBooking<T extends { options: { value: string }[] }>(locationOption: T) {
return {
...locationOption,
options: locationOption.options.filter((o) => !isAttendeeInputRequired(o.value)),
};
}
@@ -0,0 +1,225 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import * as React from "react";
import { vi } from "vitest";
import LocationSelect from "@calcom/features/form/components/LocationSelect";
import { QueryCell } from "../../../lib/QueryCell";
import { EditLocationDialog } from "../EditLocationDialog";
// // Mock the trpc hook
vi.mock("@calcom/trpc/react", () => ({
trpc: {
viewer: {
locationOptions: {
useQuery: vi.fn(),
},
},
},
}));
vi.mock("@calcom/lib/hooks/useLocale", () => ({
useLocale: () => ({ t: (key: string) => key }),
}));
vi.mock("../../../lib/QueryCell", () => ({
QueryCell: vi.fn(),
}));
vi.mock("@calcom/features/form/components/LocationSelect", () => {
return {
default: vi.fn(),
};
});
const AttendeePhoneNumberLabel = "Attendee Phone Number";
const OrganizerPhoneLabel = "Organizer Phone Number";
const CampfireLabel = "Campfire";
const ZoomVideoLabel = "Zoom Video";
const OrganizerDefaultConferencingAppLabel = "Organizer's default app";
describe("EditLocationDialog", () => {
const mockProps = {
saveLocation: vi.fn(),
setShowLocationModal: vi.fn(),
isOpenDialog: true,
};
beforeEach(() => {
vi.clearAllMocks();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
QueryCell.mockImplementation(({ success }) => {
return success({
data: [
{
label: "Conferencing",
options: [
{
value: "integrations:campfire_video",
label: CampfireLabel,
disabled: false,
icon: "/app-store/campfire/icon.svg",
slug: "campfire",
credentialId: 2,
teamName: null,
},
{
value: "integrations:daily",
label: "Cal Video (Global)",
disabled: false,
icon: "/app-store/dailyvideo/icon.svg",
slug: "daily-video",
credentialId: 0,
teamName: "Global",
},
{
value: "integrations:zoom",
label: ZoomVideoLabel,
disabled: false,
icon: "/app-store/zoomvideo/icon.svg",
slug: "zoom",
credentialId: 1,
teamName: null,
},
{
label: "Organizer's default app",
value: "conferencing",
icon: "/link.svg",
},
],
},
{
label: "in person",
options: [
{
label: "In Person (Attendee Address)",
value: "attendeeInPerson",
icon: "/map-pin-dark.svg",
},
{
label: "In Person (Organizer Address)",
value: "inPerson",
icon: "/map-pin-dark.svg",
},
],
},
{
label: "Other",
options: [
{
label: "Custom attendee location",
value: "somewhereElse",
icon: "/message-pin.svg",
},
{
label: "Link meeting",
value: "link",
icon: "/link.svg",
},
],
},
{
label: "phone",
options: [
{
label: AttendeePhoneNumberLabel,
value: "phone",
icon: "/phone.svg",
},
{
label: OrganizerPhoneLabel,
value: "userPhone",
icon: "/phone.svg",
},
],
},
],
});
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
LocationSelect.mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
({ options, defaultValue, onChange }: { options: any; defaultValue: any; onChange: any }) => {
return (
<select
data-testid="location-select"
defaultValue={defaultValue}
onChange={(e) => {
const selectedOption = options
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.flatMap((opt: any) => opt.options || [opt])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.find((opt: any) => opt.value === e.target.value);
onChange(selectedOption);
}}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{options.map((group: any) => (
<optgroup key={group.value} label={group.label}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{(group.options || [group]).map((option: any) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))}
</select>
);
}
);
});
it("renders the dialog when open", () => {
// It shows whatever the location even if it isn't in the options list
render(<EditLocationDialog {...mockProps} booking={{ location: "Office" }} />);
expect(screen.getByText("edit_location")).toBeInTheDocument();
expect(screen.getByText("current_location:")).toBeInTheDocument();
expect(screen.getByText("Office")).toBeInTheDocument();
});
it("closes the dialog when cancel is clicked", async () => {
render(<EditLocationDialog {...mockProps} booking={{ location: "Office" }} />);
fireEvent.click(screen.getByText("cancel"));
expect(mockProps.setShowLocationModal).toHaveBeenCalledWith(false);
});
describe("Team Booking Case", () => {
it("should not show Attendee Phone Number but show Organizer Phone Number and dynamic link Conferencing apps", async () => {
render(<EditLocationDialog {...mockProps} booking={{ location: "Office" }} teamId={1} />);
expect(screen.queryByText(AttendeePhoneNumberLabel)).not.toBeInTheDocument();
expect(screen.queryByText(OrganizerPhoneLabel)).toBeInTheDocument();
expect(screen.queryByText(CampfireLabel)).toBeInTheDocument();
expect(screen.queryByText(ZoomVideoLabel)).toBeInTheDocument();
});
it("should update location to Organizer Default App", async () => {
render(<EditLocationDialog {...mockProps} booking={{ location: "Office" }} teamId={1} />);
const select = screen.getByRole("combobox");
fireEvent.change(select, { target: { value: "conferencing" } });
// Submit the form
fireEvent.click(screen.getByText("update"));
await waitFor(() => {
expect(mockProps.saveLocation).toHaveBeenCalledWith({
newLocation: "conferencing",
credentialId: null,
});
expect(mockProps.setShowLocationModal).toHaveBeenCalledWith(false);
});
});
});
describe("Non Team Booking Case", () => {
it("should not show Organizer's default app", async () => {
render(<EditLocationDialog {...mockProps} booking={{ location: "Office" }} />);
expect(screen.queryByText(OrganizerDefaultConferencingAppLabel)).not.toBeInTheDocument();
});
});
});
@@ -1147,6 +1147,7 @@
"set_location": "Set Location",
"update_location": "Update Location",
"location_updated": "Location updated",
"location_update_failed": "Location update failed",
"guests_added": "Guests added",
"unable_to_add_guests": "Unable to add guests",
"email_validation_error": "That doesn't look like an email address",
@@ -1887,6 +1888,7 @@
"default_app_link_title": "Set a default app link",
"default_app_link_description": "Setting a default app link allows all newly created event types to use the app link you set.",
"organizer_default_conferencing_app": "Organizer's default app",
"organizer_default_conferencing_app_not_found": "{{organizer}} has no default conferencing app",
"under_maintenance": "Down for maintenance",
"under_maintenance_description": "The {{appName}} team are performing scheduled maintenance. If you have any questions, please contact support.",
"event_type_seats": "{{numberOfSeats}} seats",
@@ -2596,6 +2598,7 @@
"number_of_options":"{{count}} options",
"reschedule_with_same_round_robin_host_title": "Reschedule with same Round-Robin host",
"reschedule_with_same_round_robin_host_description": "Rescheduled events will be assigned to the same host as initially scheduled",
"disable_input_if_prefilled": "Disable input if the URL identifier is prefilled",
"disable_input_if_prefilled": "Disable input if the URL identifier is prefilled",
"you_are_unauthorized_to_make_this_change_to_the_booking": "You are unauthorized to make this change to the booking",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -763,6 +763,33 @@ export function expectSuccessfulBookingRescheduledEmails({
);
}
export function expectSuccesfulLocationChangeEmails({
emails,
organizer,
location,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
location: {
href: string;
linkText: string;
};
}) {
expect(emails).toHaveEmail(
{
titleTag: "location_changed_event_type_subject",
links: [
{
href: location.href,
text: location.linkText,
},
],
to: `${organizer.email}`,
},
`${organizer.email}`
);
}
export function expectAwaitingPaymentEmails({
emails,
booker,
+27 -11
View File
@@ -14,6 +14,7 @@ export type DefaultEventLocationType = {
label: string;
messageForOrganizer: string;
category: "in person" | "conferencing" | "other" | "phone";
linkType: "static";
iconUrl: string;
urlRegExp?: string;
@@ -101,6 +102,7 @@ export const defaultLocations: DefaultEventLocationType[] = [
defaultValueVariable: "attendeeAddress",
iconUrl: "/map-pin-dark.svg",
category: "in person",
linkType: "static",
},
{
default: true,
@@ -114,6 +116,7 @@ export const defaultLocations: DefaultEventLocationType[] = [
defaultValueVariable: "somewhereElse",
iconUrl: "/message-pin.svg",
category: "other",
linkType: "static",
},
{
default: true,
@@ -126,6 +129,7 @@ export const defaultLocations: DefaultEventLocationType[] = [
defaultValueVariable: "address",
iconUrl: "/map-pin-dark.svg",
category: "in person",
linkType: "static",
},
{
default: true,
@@ -137,6 +141,7 @@ export const defaultLocations: DefaultEventLocationType[] = [
defaultValueVariable: "hostDefault",
category: "conferencing",
messageForOrganizer: "",
linkType: "static",
},
{
default: true,
@@ -148,6 +153,7 @@ export const defaultLocations: DefaultEventLocationType[] = [
defaultValueVariable: "link",
iconUrl: "/link.svg",
category: "other",
linkType: "static",
},
{
default: true,
@@ -163,6 +169,7 @@ export const defaultLocations: DefaultEventLocationType[] = [
// inputType: "phone"
iconUrl: "/phone.svg",
category: "phone",
linkType: "static",
},
{
default: true,
@@ -174,6 +181,7 @@ export const defaultLocations: DefaultEventLocationType[] = [
defaultValueVariable: "hostPhoneNumber",
iconUrl: "/phone.svg",
category: "phone",
linkType: "static",
},
];
@@ -247,21 +255,20 @@ for (const [appName, meta] of Object.entries(appStoreMetadata)) {
}
}
const locationsTypes = [...defaultLocations, ...locationsFromApps];
export const getStaticLinkBasedLocation = (locationType: string) =>
locationsFromApps.find((l) => l.linkType === "static" && l.type === locationType);
const locations = [...defaultLocations, ...locationsFromApps];
export const getEventLocationTypeFromApp = (locationType: string) =>
export const getLocationFromApp = (locationType: string) =>
locationsFromApps.find((l) => l.type === locationType);
// TODO: Rename this to getLocationByType()
export const getEventLocationType = (locationType: string | undefined | null) =>
locationsTypes.find((l) => l.type === locationType);
locations.find((l) => l.type === locationType);
export const getEventLocationTypeFromValue = (value: string | undefined | null) => {
const getStaticLinkLocationByValue = (value: string | undefined | null) => {
if (!value) {
return null;
}
return locationsTypes.find((l) => {
return locations.find((l) => {
if (l.default || l.linkType == "dynamic" || !l.urlRegExp) {
return;
}
@@ -270,7 +277,7 @@ export const getEventLocationTypeFromValue = (value: string | undefined | null)
};
export const guessEventLocationType = (locationTypeOrValue: string | undefined | null) =>
getEventLocationType(locationTypeOrValue) || getEventLocationTypeFromValue(locationTypeOrValue);
getEventLocationType(locationTypeOrValue) || getStaticLinkLocationByValue(locationTypeOrValue);
export const LocationType = { ...DefaultEventLocationTypeEnum, ...AppStoreLocationType };
@@ -303,7 +310,7 @@ export const privacyFilteredLocations = (locations: LocationObject[]): PrivacyFi
* @returns string
*/
export const getMessageForOrganizer = (location: string, t: TFunction) => {
const videoLocation = getEventLocationTypeFromApp(location);
const videoLocation = getLocationFromApp(location);
const defaultLocation = defaultLocations.find((l) => l.type === location);
if (defaultLocation) {
return t(defaultLocation.messageForOrganizer);
@@ -464,8 +471,17 @@ export const getTranslatedLocation = (
export const getOrganizerInputLocationTypes = () => {
const result: DefaultEventLocationType["type"] | EventLocationTypeFromApp["type"][] = [];
const locations = locationsTypes.filter((location) => !!location.organizerInputType);
locations?.forEach((l) => result.push(l.type));
const organizerInputTypeLocations = locations.filter((location) => !!location.organizerInputType);
organizerInputTypeLocations?.forEach((l) => result.push(l.type));
return result;
};
export const isAttendeeInputRequired = (locationType: string) => {
const location = locations.find((l) => l.type === locationType);
if (!location) {
// Consider throwing an error here. This shouldn't happen normally.
return false;
}
return location.attendeeInputType;
};
+2 -2
View File
@@ -7,7 +7,7 @@ import type { z } from "zod";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { appKeysSchema as calVideoKeysSchema } from "@calcom/app-store/dailyvideo/zod";
import { getEventLocationTypeFromApp, MeetLocationType } from "@calcom/app-store/locations";
import { getLocationFromApp, MeetLocationType } from "@calcom/app-store/locations";
import getApps from "@calcom/app-store/utils";
import { getUid } from "@calcom/lib/CalEventParser";
import logger from "@calcom/lib/logger";
@@ -57,7 +57,7 @@ const latestCredentialFirst = <T extends HasId>(a: T, b: T) => {
};
export const getLocationRequestFromIntegration = (location: string) => {
const eventLocationType = getEventLocationTypeFromApp(location);
const eventLocationType = getLocationFromApp(location);
if (eventLocationType) {
const requestId = uuidv5(location, uuidv5.URL);
@@ -1396,7 +1396,7 @@ async function handler(
safeStringify({ error, results })
);
} else {
const metadata: AdditionalInformation = {};
const additionalInformation: AdditionalInformation = {};
if (results.length) {
// Handle Google Meet results
@@ -1451,12 +1451,14 @@ async function handler(
}
}
// TODO: Handle created event metadata more elegantly
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
additionalInformation.hangoutLink = results[0].createdEvent?.hangoutLink;
additionalInformation.conferenceData = results[0].createdEvent?.conferenceData;
additionalInformation.entryPoints = results[0].createdEvent?.entryPoints;
evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus);
videoCallUrl =
metadata.hangoutLink || organizerOrFirstDynamicGroupMemberDefaultLocationUrl || videoCallUrl;
additionalInformation.hangoutLink ||
organizerOrFirstDynamicGroupMemberDefaultLocationUrl ||
videoCallUrl;
if (evt.iCalUID !== booking.iCalUID) {
// The eventManager could change the iCalUID. At this point we can update the DB record
@@ -1497,7 +1499,7 @@ async function handler(
await sendScheduledEmails(
{
...evt,
additionalInformation: metadata,
additionalInformation,
additionalNotes,
customInputs,
},
@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import dayjs from "@calcom/dayjs";
import { buildCalEventFromBooking } from "../buildCalEventFromBooking";
import { parseRecurringEvent } from "../isRecurringEvent";
import { getTranslation } from "../server";
// Mock dependencies
vi.mock("../isRecurringEvent", () => ({
parseRecurringEvent: vi.fn(),
}));
vi.mock("../server", () => ({
getTranslation: vi.fn(),
}));
// Helper functions
const createOrganizer = (overrides = {}) => ({
email: "organizer@example.com",
name: "Organizer",
timeZone: "UTC",
locale: "en",
...overrides,
});
const createAttendee = (overrides = {}) => ({
name: "Attendee 1",
email: "attendee1@example.com",
timeZone: "UTC",
locale: "en",
...overrides,
});
const createBooking = (overrides = {}) => ({
title: "Test Booking",
description: "Test Description",
startTime: new Date("2023-04-01T10:00:00Z"),
endTime: new Date("2023-04-01T11:00:00Z"),
userPrimaryEmail: "user@example.com",
uid: "test-uid",
attendees: [createAttendee()],
eventType: {
title: "Test Event Type",
seatsPerTimeSlot: 5,
seatsShowAttendees: true,
recurringEvent: {
frequency: "daily",
interval: 1,
endDate: new Date("2023-04-01T11:00:00Z"),
},
},
destinationCalendar: null,
user: null,
...overrides,
});
describe("buildCalEventFromBooking", () => {
beforeEach(() => {
// vi.resetAllMocks();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
getTranslation.mockImplementation((locale: string, namespace: string) => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
const translate = () => {};
translate.locale = locale;
translate.namespace = namespace;
return translate;
});
parseRecurringEvent.mockImplementation((recurringEvent) => {
if (!recurringEvent) {
return { parsed: true };
}
return { ...recurringEvent, parsed: true };
});
});
it("should build a calendar event from a booking", async () => {
const booking = createBooking({
title: "Booking Title",
});
const organizer = createOrganizer();
const location = "Test Location";
const conferenceCredentialId = 123;
const result = await buildCalEventFromBooking({
booking,
organizer,
location,
conferenceCredentialId,
});
expect(result).toEqual({
title: booking.title,
type: booking.eventType.title,
description: booking.description,
startTime: dayjs(booking.startTime).format(),
endTime: dayjs(booking.endTime).format(),
organizer: {
email: booking.userPrimaryEmail,
name: organizer.name,
timeZone: organizer.timeZone,
language: { translate: expect.any(Function), locale: "en" },
},
attendees: [
{
name: booking.attendees[0].name,
email: booking.attendees[0].email,
timeZone: booking.attendees[0].timeZone,
language: { translate: expect.any(Function), locale: "en" },
},
],
uid: booking.uid,
recurringEvent: {
...booking.eventType?.recurringEvent,
parsed: true,
},
location,
conferenceCredentialId: conferenceCredentialId,
destinationCalendar: [],
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: true,
});
expect(parseRecurringEvent).toHaveBeenCalledWith(booking.eventType?.recurringEvent);
});
it("should handle missing optional fields", async () => {
const booking = createBooking({
title: "",
description: null,
startTime: null,
endTime: null,
userPrimaryEmail: null,
attendees: [],
eventType: null,
});
const organizer = createOrganizer({ name: null, locale: null });
const location = "";
const conferenceCredentialId = null;
const result = await buildCalEventFromBooking({
booking,
organizer,
location,
conferenceCredentialId,
});
expect(result).toEqual({
title: booking.title,
type: "",
description: "",
startTime: "",
endTime: "",
organizer: {
email: organizer.email,
name: "Nameless",
timeZone: organizer.timeZone,
language: { translate: expect.any(Function), locale: "en" },
},
attendees: [],
uid: "test-uid",
recurringEvent: {
parsed: true,
},
location: "",
conferenceCredentialId: undefined,
destinationCalendar: [],
seatsPerTimeSlot: undefined,
seatsShowAttendees: undefined,
});
// @ts-expect-error - locale is set in mock
expect(result.organizer.language.translate.locale).toBe("en");
// @ts-expect-error - namespace is set in mock
expect(result.organizer.language.translate.namespace).toBe("common");
});
it("should use user destination calendar when booking destination calendar is null", async () => {
const booking = createBooking({
destinationCalendar: null,
user: {
destinationCalendar: {
id: 1,
integration: "test-integration",
externalId: "external-id",
primaryEmail: "user@example.com",
userId: 1,
eventTypeId: 1,
credentialId: 1,
},
},
});
const organizer = createOrganizer();
const result = await buildCalEventFromBooking({
booking,
organizer,
location: "",
conferenceCredentialId: null,
});
expect(result.destinationCalendar).toEqual([booking.user.destinationCalendar]);
});
});
+106
View File
@@ -0,0 +1,106 @@
import type { Prisma } from "@prisma/client";
import dayjs from "@calcom/dayjs";
import { parseRecurringEvent } from "./isRecurringEvent";
import { getTranslation } from "./server";
type DestinationCalendar = {
id: number;
integration: string;
externalId: string;
primaryEmail: string | null;
userId: number | null;
eventTypeId: number | null;
credentialId: number | null;
} | null;
type Attendee = {
email: string;
name: string;
timeZone: string;
locale: string | null;
};
type Organizer = {
email: string;
name: string | null;
timeZone: string;
locale: string | null;
};
type EventType = {
title: string;
recurringEvent: Prisma.JsonValue | null;
seatsPerTimeSlot: number | null;
seatsShowAttendees: boolean | null;
};
type Booking = {
title: string;
description: string | null;
startTime: Date | null;
endTime: Date | null;
userPrimaryEmail: string | null;
uid: string;
destinationCalendar: DestinationCalendar;
user: {
destinationCalendar: DestinationCalendar;
} | null;
attendees: Attendee[];
eventType: EventType | null;
};
export const buildCalEventFromBooking = async ({
booking,
organizer,
location,
conferenceCredentialId,
}: {
booking: Booking;
organizer: Organizer;
location: string;
conferenceCredentialId: number | null;
}) => {
const attendeesList = await Promise.all(
booking.attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
})
);
const tOrganizer = await getTranslation(organizer.locale ?? "en", "common");
return {
title: booking.title || "",
type: (booking.eventType?.title as string) || booking.title || "",
description: booking.description || "",
startTime: booking.startTime ? dayjs(booking.startTime).format() : "",
endTime: booking.endTime ? dayjs(booking.endTime).format() : "",
organizer: {
email: booking.userPrimaryEmail ?? organizer.email,
name: organizer.name ?? "Nameless",
timeZone: organizer.timeZone,
language: { translate: tOrganizer, locale: organizer.locale ?? "en" },
},
attendees: attendeesList,
uid: booking.uid,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
location,
conferenceCredentialId: conferenceCredentialId ?? undefined,
destinationCalendar: booking.destinationCalendar
? [booking.destinationCalendar]
: booking.user?.destinationCalendar
? [booking.user?.destinationCalendar]
: [],
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
};
};
+25
View File
@@ -171,4 +171,29 @@ export class BookingRepository {
},
});
}
static async updateLocationById({
where: { id },
data: { location, metadata, referencesToCreate },
}: {
where: { id: number };
data: {
location: string;
metadata: Record<string, unknown>;
referencesToCreate: Prisma.BookingReferenceCreateInput[];
};
}) {
await prisma.booking.update({
where: {
id,
},
data: {
location,
metadata,
references: {
create: referencesToCreate,
},
},
});
}
}
@@ -1,9 +1,27 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@calcom/prisma";
import { safeCredentialSelect } from "@calcom/prisma/selects/credential";
export class CredentialRepository {
static async create(data: Prisma.CredentialCreateInput) {
return await prisma.credential.create({ data });
}
/**
* Doesn't retrieve key field as that has credentials
*/
static async findFirstByIdWithUser({ id }: { id: number }) {
return await prisma.credential.findFirst({ where: { id }, select: safeCredentialSelect });
}
/**
* Includes 'key' field which is sensitive data.
*/
static async findFirstByIdWithKeyAndUser({ id }: { id: number }) {
return await prisma.credential.findFirst({
where: { id },
select: { ...safeCredentialSelect, key: true },
});
}
}
+12
View File
@@ -9,6 +9,7 @@ import prisma from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import type { User as UserType } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { userMetadata } from "@calcom/prisma/zod-utils";
import type { UpId, UserProfile } from "@calcom/types/UserProfile";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "../../availability";
@@ -249,6 +250,17 @@ export class UserRepository {
if (!user) {
return null;
}
return {
...user,
metadata: userMetadata.parse(user.metadata),
};
}
static async findByIdOrThrow({ id }: { id: number }) {
const user = await UserRepository.findById({ id });
if (!user) {
throw new Error(`User with id ${id} not found`);
}
return user;
}
@@ -0,0 +1,264 @@
import {
createBookingScenario,
getOrganizer,
TestData,
getScenarioData,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { getZoomAppCredential } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { expectSuccesfulLocationChangeEmails } from "@calcom/web/test/utils/bookingScenario/expects";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe, expect, vi, beforeEach } from "vitest";
import { prisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
import {
editLocationHandler,
getLocationForOrganizerDefaultConferencingAppInEvtFormat,
SystemError,
UserError,
} from "../editLocation.handler";
describe("getLocationForOrganizerDefaultConferencingAppInEvtFormat", () => {
const mockTranslate = vi.fn((key: string) => key);
beforeEach(() => {
vi.resetAllMocks();
});
describe("Dynamic link apps", () => {
test("should return the app type for Zoom", () => {
const organizer = {
name: "Test Organizer",
metadata: {
defaultConferencingApp: {
appSlug: "zoom",
},
},
};
const result = getLocationForOrganizerDefaultConferencingAppInEvtFormat({
organizer,
loggedInUserTranslate: mockTranslate,
});
expect(result).toBe("integrations:zoom");
});
test("should return the app type for Google Meet", () => {
const organizer = {
name: "Test Organizer",
metadata: {
defaultConferencingApp: {
appSlug: "google-meet",
},
},
};
const result = getLocationForOrganizerDefaultConferencingAppInEvtFormat({
organizer,
loggedInUserTranslate: mockTranslate,
});
expect(result).toBe("integrations:google:meet");
});
});
describe("Static link apps", () => {
test("should return the app type for Campfire", () => {
const organizer = {
name: "Test Organizer",
metadata: {
defaultConferencingApp: {
appSlug: "campfire",
appLink: "https://campfire.com",
},
},
};
const result = getLocationForOrganizerDefaultConferencingAppInEvtFormat({
organizer,
loggedInUserTranslate: mockTranslate,
});
expect(result).toBe("https://campfire.com");
});
});
describe("Error handling", () => {
test("should throw a UserError if defaultConferencingApp is not set", () => {
const organizer = {
name: "Test Organizer",
metadata: null,
};
expect(() =>
getLocationForOrganizerDefaultConferencingAppInEvtFormat({
organizer,
loggedInUserTranslate: mockTranslate,
})
).toThrow(UserError);
expect(mockTranslate).toHaveBeenCalledWith("organizer_default_conferencing_app_not_found", {
organizer: "Test Organizer",
});
});
test("should throw a SystemError if the app is not found", () => {
const organizer = {
name: "Test Organizer",
metadata: {
defaultConferencingApp: {
appSlug: "invalid-app",
},
},
};
expect(() =>
getLocationForOrganizerDefaultConferencingAppInEvtFormat({
organizer,
loggedInUserTranslate: mockTranslate,
})
).toThrow(SystemError);
});
test("should throw a SystemError for static link apps if appLink is missing", () => {
const organizer = {
name: "Test Organizer",
metadata: {
defaultConferencingApp: {
appSlug: "no-link-app",
},
},
};
expect(() =>
getLocationForOrganizerDefaultConferencingAppInEvtFormat({
organizer,
loggedInUserTranslate: mockTranslate,
})
).toThrow(SystemError);
});
});
});
describe("editLocation.handler", () => {
setupAndTeardown();
describe("Changing organizer default conferencing app", () => {
test("should update the booking location when organizer's default conferencing app changes", async ({
emails,
}) => {
const scenarioData = {
organizer: getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getZoomAppCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: "google_calendar",
externalId: "organizer@google-calendar.com",
},
metadata: {
defaultConferencingApp: {
appSlug: "campfire",
appLink: "https://campfire.com",
},
},
}),
eventTypes: [
{
id: 1,
slotInterval: 45,
length: 45,
users: [{ id: 101 }],
},
],
bookings: [
{
id: 1,
uid: "booking-1",
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
startTime: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
endTime: new Date(Date.now() + 25 * 60 * 60 * 1000).toISOString(),
userId: 101,
attendees: [
{
id: 102,
name: "Attendee 1",
email: "attendee1@example.com",
timeZone: "Asia/Kolkata",
},
],
},
],
apps: [TestData.apps["zoom"], TestData.apps["google-meet"]],
};
await createBookingScenario(getScenarioData(scenarioData));
const booking = await prisma.booking.findFirst({
where: {
uid: scenarioData.bookings[0].uid,
},
include: {
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
user: true,
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
attendees: true,
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
references: true,
},
});
const organizerUser = await prisma.user.findFirst({
where: {
id: scenarioData.organizer.id,
},
});
expect(booking).not.toBeNull();
// Simulate changing the organizer's default conferencing app to Google Meet
const updatedOrganizer = {
...booking.user,
metadata: {
...booking.user.metadata,
defaultConferencingApp: {
appSlug: "google-meet",
},
},
};
await editLocationHandler({
ctx: {
booking,
user: organizerUser,
},
input: {
newLocation: "conferencing",
},
currentUserId: updatedOrganizer.id,
});
const updatedBooking = await prisma.booking.findFirstOrThrow({
where: {
uid: scenarioData.bookings[0].uid,
},
});
expect(updatedBooking.location).toBe("https://campfire.com");
expectSuccesfulLocationChangeEmails({
emails,
organizer: organizerUser,
location: {
href: "https://campfire.com",
linkText: "Link",
},
});
});
});
});
@@ -1,15 +1,25 @@
import type { z } from "zod";
import { getEventLocationType, OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import EventManager from "@calcom/core/EventManager";
import dayjs from "@calcom/dayjs";
import { sendLocationChangeEmails } from "@calcom/emails";
import { parseRecurringEvent } from "@calcom/lib";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import { buildCalEventFromBooking } from "@calcom/lib/buildCalEventFromBooking";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server";
import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { Prisma, Booking, BookingReference } from "@calcom/prisma/client";
import type { userMetadata } from "@calcom/prisma/zod-utils";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { Ensure } from "@calcom/types/utils";
import { TRPCError } from "@trpc/server";
@@ -17,6 +27,7 @@ import type { TrpcSessionUser } from "../../../trpc";
import type { TEditLocationInputSchema } from "./editLocation.schema";
import type { BookingsProcedureContext } from "./util";
// #region EditLocation Types and Helpers
type EditLocationOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
@@ -24,124 +35,255 @@ type EditLocationOptions = {
input: TEditLocationInputSchema;
};
export const editLocationHandler = async ({ ctx, input }: EditLocationOptions) => {
const { bookingId, newLocation: location, details } = input;
const { booking } = ctx;
type UserMetadata = z.infer<typeof userMetadata>;
async function updateLocationInConnectedAppForBooking({
evt,
eventManager,
booking,
}: {
evt: CalendarEvent;
eventManager: EventManager;
booking: Booking & {
references: BookingReference[];
};
}) {
const updatedResult = await eventManager.updateLocation(evt, booking);
const results = updatedResult.results;
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingUpdateLocationFailed",
message: "Updating location failed",
};
logger.error(`Updating location failed`, safeStringify(error), safeStringify(results));
throw new SystemError("Updating location failed");
}
logger.info(`Got results from updateLocationInConnectedApp`, safeStringify(updatedResult.results));
return updatedResult;
}
function extractAdditionalInformation(result: {
updatedEvent: AdditionalInformation;
}): AdditionalInformation {
const additionalInformation: AdditionalInformation = {};
if (result) {
additionalInformation.hangoutLink = result.updatedEvent?.hangoutLink;
additionalInformation.conferenceData = result.updatedEvent?.conferenceData;
additionalInformation.entryPoints = result.updatedEvent?.entryPoints;
}
return additionalInformation;
}
async function updateBookingLocationInDb({
booking,
evt,
referencesToCreate,
}: {
booking: {
id: number;
metadata: Booking["metadata"];
};
evt: Ensure<CalendarEvent, "location">;
referencesToCreate: Prisma.BookingReferenceCreateInput[];
}) {
const bookingMetadataUpdate = {
videoCallUrl: getVideoCallUrlFromCalEvent(evt),
};
await BookingRepository.updateLocationById({
data: {
location: evt.location,
metadata: {
...(typeof booking.metadata === "object" && booking.metadata),
...bookingMetadataUpdate,
},
referencesToCreate,
},
where: {
id: booking.id,
},
});
await prisma.booking.update({
where: {
id: booking.id,
},
data: {
location: evt.location,
metadata: {
...(typeof booking.metadata === "object" && booking.metadata),
...bookingMetadataUpdate,
},
references: {
create: referencesToCreate,
},
},
});
}
async function getAllCredentials({
user,
conferenceCredentialId,
}: {
user: { id: number };
conferenceCredentialId: number | null;
}) {
const credentials = await getUsersCredentials(user);
let conferenceCredential: CredentialPayload | null = null;
if (conferenceCredentialId) {
conferenceCredential = await CredentialRepository.findFirstByIdWithKeyAndUser({
id: conferenceCredentialId,
});
}
return [...(credentials ? credentials : []), ...(conferenceCredential ? [conferenceCredential] : [])];
}
async function getLocationInEvtFormatOrThrow({
location,
organizer,
loggedInUserTranslate,
}: {
location: string;
organizer: {
name: string | null;
metadata: UserMetadata;
};
loggedInUserTranslate: Awaited<ReturnType<typeof getTranslation>>;
}) {
if (location !== OrganizerDefaultConferencingAppType) {
return location;
}
try {
const organizer = await prisma.user.findFirstOrThrow({
where: {
id: booking.userId || 0,
},
select: {
name: true,
email: true,
timeZone: true,
locale: true,
},
});
let conferenceCredential: CredentialPayload | null = null;
if (details?.credentialId) {
conferenceCredential = await prisma.credential.findFirst({
where: {
id: details.credentialId,
},
select: credentialForCalendarServiceSelect,
});
}
const tOrganizer = await getTranslation(organizer.locale ?? "en", "common");
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const evt: CalendarEvent = {
title: booking.title || "",
type: (booking.eventType?.title as string) || booking?.title || "",
description: booking.description || "",
startTime: booking.startTime ? dayjs(booking.startTime).format() : "",
endTime: booking.endTime ? dayjs(booking.endTime).format() : "",
return getLocationForOrganizerDefaultConferencingAppInEvtFormat({
organizer: {
email: booking?.userPrimaryEmail ?? organizer.email,
name: organizer.name ?? "Nameless",
timeZone: organizer.timeZone,
language: { translate: tOrganizer, locale: organizer.locale ?? "en" },
name: organizer.name ?? "Organizer",
metadata: organizer.metadata,
},
attendees: attendeesList,
uid: booking.uid,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
location,
conferenceCredentialId: details?.credentialId,
destinationCalendar: booking?.destinationCalendar
? [booking?.destinationCalendar]
: booking?.user?.destinationCalendar
? [booking?.user?.destinationCalendar]
: [],
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
};
const credentials = await getUsersCredentials(ctx.user);
const eventManager = new EventManager({
...ctx.user,
credentials: [
...(credentials ? credentials : []),
...(conferenceCredential ? [conferenceCredential] : []),
],
loggedInUserTranslate,
});
const updatedResult = await eventManager.updateLocation(evt, booking);
const results = updatedResult.results;
if (results.length > 0 && results.every((res) => !res.success)) {
const error = {
errorCode: "BookingUpdateLocationFailed",
message: "Updating location failed",
};
logger.error(`Booking ${ctx.user.username} failed`, error, results);
} else {
await prisma.booking.update({
where: {
id: bookingId,
},
data: {
location,
references: {
create: updatedResult.referencesToCreate,
},
},
});
const metadata: AdditionalInformation = {};
if (results.length) {
metadata.hangoutLink = results[0].updatedEvent?.hangoutLink;
metadata.conferenceData = results[0].updatedEvent?.conferenceData;
metadata.entryPoints = results[0].updatedEvent?.entryPoints;
}
try {
await sendLocationChangeEmails(
{ ...evt, additionalInformation: metadata },
booking?.eventType?.metadata as EventTypeMetadata
);
} catch (error) {
console.log("Error sending LocationChangeEmails");
}
} catch (e) {
if (e instanceof UserError) {
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
}
} catch {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
logger.error(safeStringify(e));
throw e;
}
}
// #endregion
/**
* An error that should be shown to the user
*/
export class UserError extends Error {
constructor(message: string) {
super(message);
this.name = "LocationError";
}
}
/**
* An error that should not be shown to the user
*/
export class SystemError extends Error {
constructor(message: string) {
super(message);
this.name = "SystemError";
}
}
export function getLocationForOrganizerDefaultConferencingAppInEvtFormat({
organizer,
loggedInUserTranslate: translate,
}: {
organizer: {
name: string;
metadata: {
defaultConferencingApp?: NonNullable<UserMetadata>["defaultConferencingApp"];
} | null;
};
/**
* translate is used to translate if any error is thrown
*/
loggedInUserTranslate: Awaited<ReturnType<typeof getTranslation>>;
}) {
const organizerMetadata = organizer.metadata;
const defaultConferencingApp = organizerMetadata?.defaultConferencingApp;
if (!defaultConferencingApp) {
throw new UserError(
translate("organizer_default_conferencing_app_not_found", { organizer: organizer.name })
);
}
const defaultConferencingAppSlug = defaultConferencingApp.appSlug;
const app = getAppFromSlug(defaultConferencingAppSlug);
if (!app) {
throw new SystemError(`Default conferencing app ${defaultConferencingAppSlug} not found`);
}
const defaultConferencingAppLocationType = app.appData?.location?.type;
if (!defaultConferencingAppLocationType) {
throw new SystemError("Default conferencing app has no location type");
}
const location = defaultConferencingAppLocationType;
const locationType = getEventLocationType(location);
if (!locationType) {
throw new SystemError(`Location type not found: ${location}`);
}
if (locationType.linkType === "dynamic") {
// Dynamic location type need to return the location as it is e.g. integrations:zoom_video
return location;
}
const appLink = defaultConferencingApp.appLink;
if (!appLink) {
throw new SystemError(`Default conferencing app ${defaultConferencingAppSlug} has no app link`);
}
return appLink;
}
export async function editLocationHandler({ ctx, input }: EditLocationOptions) {
const { newLocation, credentialId: conferenceCredentialId } = input;
const { booking, user: loggedInUser } = ctx;
const organizer = await UserRepository.findByIdOrThrow({ id: booking.userId || 0 });
const newLocationInEvtFormat = await getLocationInEvtFormatOrThrow({
location: newLocation,
organizer,
loggedInUserTranslate: await getTranslation(loggedInUser.locale ?? "en", "common"),
});
const evt = await buildCalEventFromBooking({
booking,
organizer,
location: newLocationInEvtFormat,
conferenceCredentialId,
});
const eventManager = new EventManager({
...ctx.user,
credentials: await getAllCredentials({ user: ctx.user, conferenceCredentialId }),
});
const updatedResult = await updateLocationInConnectedAppForBooking({
booking,
eventManager,
evt,
});
await updateBookingLocationInDb({ booking, evt, referencesToCreate: updatedResult.referencesToCreate });
try {
await sendLocationChangeEmails(
{ ...evt, additionalInformation: extractAdditionalInformation(updatedResult.results[0]) },
booking?.eventType?.metadata as EventTypeMetadata
);
} catch (error) {
console.log("Error sending LocationChangeEmails", safeStringify(error));
}
return { message: "Location updated" };
};
}
@@ -6,7 +6,7 @@ import { commonBookingSchema } from "./types";
export const ZEditLocationInputSchema = commonBookingSchema.extend({
newLocation: z.string().transform((val) => val || DailyLocationType),
details: z.object({ credentialId: z.number().optional() }).optional(),
credentialId: z.number().nullable(),
});
export type TEditLocationInputSchema = z.infer<typeof ZEditLocationInputSchema>;
@@ -1,11 +1,12 @@
import type {
Attendee,
Booking,
BookingReference,
Credential,
DestinationCalendar,
EventType,
User,
import {
MembershipRole,
type Attendee,
type Booking,
type BookingReference,
type Credential,
type DestinationCalendar,
type EventType,
type User,
} from "@prisma/client";
import { prisma } from "@calcom/prisma";
@@ -21,8 +22,44 @@ export const bookingsProcedure = authedProcedure
.use(async ({ ctx, input, next }) => {
// Endpoints that just read the logged in user's data - like 'list' don't necessary have any input
const { bookingId } = input;
const loggedInUser = ctx.user;
const bookingInclude = {
attendees: true,
eventType: true,
destinationCalendar: true,
references: true,
user: {
include: {
destinationCalendar: true,
credentials: true,
},
},
};
const booking = await prisma.booking.findFirst({
const bookingByBeingAdmin = await prisma.booking.findFirst({
where: {
id: bookingId,
eventType: {
team: {
members: {
some: {
userId: loggedInUser.id,
role: {
in: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
},
},
},
},
include: bookingInclude,
});
if (!!bookingByBeingAdmin) {
return next({ ctx: { booking: bookingByBeingAdmin } });
}
const bookingByBeingOrganizerOrCollectiveEventMember = await prisma.booking.findFirst({
where: {
id: bookingId,
AND: [
@@ -45,23 +82,12 @@ export const bookingsProcedure = authedProcedure
},
],
},
include: {
attendees: true,
eventType: true,
destinationCalendar: true,
references: true,
user: {
include: {
destinationCalendar: true,
credentials: true,
},
},
},
include: bookingInclude,
});
if (!booking) throw new TRPCError({ code: "UNAUTHORIZED" });
if (!bookingByBeingOrganizerOrCollectiveEventMember) throw new TRPCError({ code: "UNAUTHORIZED" });
return next({ ctx: { booking } });
return next({ ctx: { booking: bookingByBeingOrganizerOrCollectiveEventMember } });
});
export type BookingsProcedureContext = {
+4
View File
@@ -1,7 +1,11 @@
import matchers from "@testing-library/jest-dom/matchers";
import { cleanup } from "@testing-library/react";
import React from "react";
import { afterEach, expect, vi } from "vitest";
// For next.js webapp compponent that use "preserve" for jsx in tsconfig.json
global.React = React;
vi.mock("next-auth/react", () => ({
useSession() {
return {};
+9
View File
@@ -168,6 +168,15 @@ const workspaces = packagedEmbedTestsOnly
setupFiles: ["packages/ui/components/test-setup.ts"],
},
},
{
test: {
globals: true,
name: "@calcom/web/components",
include: ["apps/web/components/**/*.{test,spec}.[jt]sx"],
environment: "jsdom",
setupFiles: ["packages/ui/components/test-setup.ts"],
},
},
{
test: {
globals: true,