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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user