diff --git a/apps/web/components/apps/AppList.tsx b/apps/web/components/apps/AppList.tsx index 2b73559cf3..0c514d4609 100644 --- a/apps/web/components/apps/AppList.tsx +++ b/apps/web/components/apps/AppList.tsx @@ -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 { diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 0e8fb2a1a1..69c61e2cbd 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -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 = { + 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 diff --git a/apps/web/components/dialog/EditLocationDialog.tsx b/apps/web/components/dialog/EditLocationDialog.tsx index c07bd7154f..279dc5202a 100644 --- a/apps/web/components/dialog/EditLocationDialog.tsx +++ b/apps/web/components/dialog/EditLocationDialog.tsx @@ -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; selection?: LocationOption; - booking?: BookingItem; + booking: { + location: string | null; + }; defaultValues?: LocationObject[]; setShowLocationModal: React.Dispatch>; 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" /> - {!booking && ( -
- ( - - locationFormMethods.setValue("displayLocationPublicly", e.target.checked) - } - informationIconText={t("display_location_info_badge")} - /> - )} - /> -
- )} ); } else { @@ -241,91 +228,51 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { - {!booking && ( -

- - Can't find the right conferencing app? Visit our - - App Store - - . - -

- )}
- {booking && ( - <> -

{t("current_location")}:

-

- {getHumanReadableLocationValue(booking.location, t)} -

- - )} +

{t("current_location")}:

+

+ {getHumanReadableLocationValue(booking.location, t)} +

{ - 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", - ]); }}> { 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 ( { 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} - @@ -407,3 +348,10 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { ); }; + +function filterLocationOptionsForBooking(locationOption: T) { + return { + ...locationOption, + options: locationOption.options.filter((o) => !isAttendeeInputRequired(o.value)), + }; +} diff --git a/apps/web/components/dialog/__tests__/EditLocationDialog.test.tsx b/apps/web/components/dialog/__tests__/EditLocationDialog.test.tsx new file mode 100644 index 0000000000..4157885536 --- /dev/null +++ b/apps/web/components/dialog/__tests__/EditLocationDialog.test.tsx @@ -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 ( + + ); + } + ); + }); + + it("renders the dialog when open", () => { + // It shows whatever the location even if it isn't in the options list + render(); + 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(); + 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(); + + 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(); + + 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(); + + expect(screen.queryByText(OrganizerDefaultConferencingAppLabel)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 57177a7521..0834ae2293 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 61568a76fe..48f6f818e0 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -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, diff --git a/packages/app-store/locations.ts b/packages/app-store/locations.ts index fd40c1240a..52255bb273 100644 --- a/packages/app-store/locations.ts +++ b/packages/app-store/locations.ts @@ -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; +}; diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index 700b1c52d2..40799a2c30 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -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 = (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); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index dc99444a70..a7ab6b65bd 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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, }, diff --git a/packages/lib/__tests__/buildCalEventFromBooking.test.ts b/packages/lib/__tests__/buildCalEventFromBooking.test.ts new file mode 100644 index 0000000000..d88a07c6df --- /dev/null +++ b/packages/lib/__tests__/buildCalEventFromBooking.test.ts @@ -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]); + }); +}); diff --git a/packages/lib/buildCalEventFromBooking.ts b/packages/lib/buildCalEventFromBooking.ts new file mode 100644 index 0000000000..ede1d436f1 --- /dev/null +++ b/packages/lib/buildCalEventFromBooking.ts @@ -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, + }; +}; diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index b5d85a31f0..87581b3856 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -171,4 +171,29 @@ export class BookingRepository { }, }); } + + static async updateLocationById({ + where: { id }, + data: { location, metadata, referencesToCreate }, + }: { + where: { id: number }; + data: { + location: string; + metadata: Record; + referencesToCreate: Prisma.BookingReferenceCreateInput[]; + }; + }) { + await prisma.booking.update({ + where: { + id, + }, + data: { + location, + metadata, + references: { + create: referencesToCreate, + }, + }, + }); + } } diff --git a/packages/lib/server/repository/credential.ts b/packages/lib/server/repository/credential.ts index b5a81f36a3..8f2d50a287 100644 --- a/packages/lib/server/repository/credential.ts +++ b/packages/lib/server/repository/credential.ts @@ -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 }, + }); + } } diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index 91c7a29f6c..e67f341599 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -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; } diff --git a/packages/trpc/server/routers/viewer/bookings/__tests__/editLocation.handler.test.ts b/packages/trpc/server/routers/viewer/bookings/__tests__/editLocation.handler.test.ts new file mode 100644 index 0000000000..0b6c1e0425 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/__tests__/editLocation.handler.test.ts @@ -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", + }, + }); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts index f8fcd4bd54..cb6ae56738 100644 --- a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts @@ -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; @@ -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; + +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; + 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>; +}) { + 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["defaultConferencingApp"]; + } | null; + }; + /** + * translate is used to translate if any error is thrown + */ + loggedInUserTranslate: Awaited>; +}) { + 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" }; -}; +} diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.schema.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.schema.ts index fb6e67b90f..2fbf0e8a4e 100644 --- a/packages/trpc/server/routers/viewer/bookings/editLocation.schema.ts +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.schema.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/bookings/util.ts b/packages/trpc/server/routers/viewer/bookings/util.ts index ae8668987c..fdf5ad7b88 100644 --- a/packages/trpc/server/routers/viewer/bookings/util.ts +++ b/packages/trpc/server/routers/viewer/bookings/util.ts @@ -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 = { diff --git a/packages/ui/components/test-setup.ts b/packages/ui/components/test-setup.ts index 36cbce4842..0650038897 100644 --- a/packages/ui/components/test-setup.ts +++ b/packages/ui/components/test-setup.ts @@ -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 {}; diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 3e2c579155..befcacc264 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -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,