From 88d438f2270ea31ac86d0301df5bcc9151df70b5 Mon Sep 17 00:00:00 2001 From: Roland <33993199+rolznz@users.noreply.github.com> Date: Mon, 9 Oct 2023 00:57:45 +0700 Subject: [PATCH 001/120] fix: icon size on connect with Alby button (#11746) --- packages/app-store/alby/pages/setup/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app-store/alby/pages/setup/index.tsx b/packages/app-store/alby/pages/setup/index.tsx index 27b13310ba..9017af73ec 100644 --- a/packages/app-store/alby/pages/setup/index.tsx +++ b/packages/app-store/alby/pages/setup/index.tsx @@ -122,9 +122,9 @@ function AlbySetupPage(props: IAlbySetupProps) { const albyIcon = ( <> - Alby Icon + Alby Icon Alby Icon From f3623096e41762319c5ed94b839ffa91d6e38bca Mon Sep 17 00:00:00 2001 From: Saquib Ali <54936572+saquib1998@users.noreply.github.com> Date: Mon, 9 Oct 2023 01:53:23 +0530 Subject: [PATCH 002/120] fix: decreased top and bottom padding (#11750) Co-authored-by: Saquib Ali --- packages/features/shell/Shell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index 80c428dfe6..0b7aee0f4a 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -1008,7 +1008,7 @@ function MainContainer({
{/* show top navigation for md and smaller (tablet and phones) */} {TopNavContainerProp} -
+
{!props.withoutMain ? {props.children} : props.children} From 5b7224e47ef08f1272727c3881bc9a2874c5e27d Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sun, 8 Oct 2023 20:26:14 +0000 Subject: [PATCH 003/120] New Crowdin translations by Github Action --- apps/web/public/static/locales/lv/common.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 apps/web/public/static/locales/lv/common.json diff --git a/apps/web/public/static/locales/lv/common.json b/apps/web/public/static/locales/lv/common.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/apps/web/public/static/locales/lv/common.json @@ -0,0 +1 @@ +{} From a35750734c274f68444fed11e92e6395d6f9f20a Mon Sep 17 00:00:00 2001 From: "GitStart-Cal.com" <121884634+gitstart-calcom@users.noreply.github.com> Date: Mon, 9 Oct 2023 08:28:28 +0000 Subject: [PATCH 004/120] fix: InputField storybook file is not rendering (fix-inputField) (#10938) Co-authored-by: gitstart-calcom --- packages/ui/components/form/inputs/inputs.stories.mdx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/ui/components/form/inputs/inputs.stories.mdx b/packages/ui/components/form/inputs/inputs.stories.mdx index 84d6367e46..7d60a026f5 100644 --- a/packages/ui/components/form/inputs/inputs.stories.mdx +++ b/packages/ui/components/form/inputs/inputs.stories.mdx @@ -1,22 +1,20 @@ -import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs"; +import { Canvas, Meta, Story } from "@storybook/addon-docs"; import { Examples, Example, - Note, Title, CustomArgsTable, VariantRow, VariantsTable, } from "@calcom/storybook/components"; -import { Select, UnstyledSelect } from "../select"; -import { InputFieldWithSelect } from "./Input"; -import { InputField } from "./Input"; +import { InputFieldWithSelect } from "./InputFieldWithSelect"; +import { InputField } from "./TextField"; - +<Title title="Inputs" suffix="Brief" subtitle="Version 2.0 — Last Update: 24 Aug 2023" /> ## Definition From 9857b461213e6bbc1711278c5b0426d3ae448325 Mon Sep 17 00:00:00 2001 From: "GitStart-Cal.com" <121884634+gitstart-calcom@users.noreply.github.com> Date: Mon, 9 Oct 2023 08:45:54 +0000 Subject: [PATCH 005/120] chore: add Navigation in storybook (CALCOM-10760) (#10867) Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com> Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> --- packages/lib/hooks/useUrlMatchesCurrentUrl.ts | 2 +- .../__stories__/horizontalTabs.stories.mdx | 134 +++++++++++++++ .../tabs/__stories__/verticalTabs.stories.mdx | 158 ++++++++++++++++++ 3 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 packages/ui/components/navigation/tabs/__stories__/horizontalTabs.stories.mdx create mode 100644 packages/ui/components/navigation/tabs/__stories__/verticalTabs.stories.mdx diff --git a/packages/lib/hooks/useUrlMatchesCurrentUrl.ts b/packages/lib/hooks/useUrlMatchesCurrentUrl.ts index 5526f2fbe2..7fc0c5fe2c 100644 --- a/packages/lib/hooks/useUrlMatchesCurrentUrl.ts +++ b/packages/lib/hooks/useUrlMatchesCurrentUrl.ts @@ -7,7 +7,7 @@ export const useUrlMatchesCurrentUrl = (url: string) => { // It can certainly have null value https://nextjs.org/docs/app/api-reference/functions/use-pathname#:~:text=usePathname%20can%20return%20null%20when%20a%20fallback%20route%20is%20being%20rendered%20or%20when%20a%20pages%20directory%20page%20has%20been%20automatically%20statically%20optimized%20by%20Next.js%20and%20the%20router%20is%20not%20ready. const pathname = usePathname() as null | string; const searchParams = useSearchParams(); - const query = searchParams.toString(); + const query = searchParams?.toString(); let pathnameWithQuery; if (query) { pathnameWithQuery = `${pathname}?${query}`; diff --git a/packages/ui/components/navigation/tabs/__stories__/horizontalTabs.stories.mdx b/packages/ui/components/navigation/tabs/__stories__/horizontalTabs.stories.mdx new file mode 100644 index 0000000000..701cd54c8e --- /dev/null +++ b/packages/ui/components/navigation/tabs/__stories__/horizontalTabs.stories.mdx @@ -0,0 +1,134 @@ +import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks"; + +import { + Title, + VariantRow, + VariantsTable, + CustomArgsTable, + Examples, + Example, +} from "@calcom/storybook/components"; +import { Plus } from "@calcom/ui/components/icon"; + +import HorizontalTabs from "../HorizontalTabs"; + +<Meta title="UI/Navigation/HorizontalTabs" component={HorizontalTabs} /> + +<Title title="Horizontal Tabs" suffix="Brief" subtitle="Version 1.0 — Last Update: 17 Aug 2023" /> + +## Definition + +The HorizontalTabs component is a user interface element used for displaying a horizontal set of tabs, often employed for navigation or organization purposes within a web application. + +## Structure + +The HorizontalTabs component is designed to work alongside the HorizontalTabItem component, which represents individual tabs within the tab bar. + +export const tabs = [ + { + name: "Tab 1", + href: "?path=/story/ui-navigation-horizontaltabs--horizontal-tabs/tab1", + disabled: false, + linkShallow: true, + linkScroll: true, + icon: Plus, + }, + { + name: "Tab 2", + href: "?path=/story/ui-navigation-horizontaltabs--horizontal-tabs/tab2", + disabled: false, + linkShallow: true, + linkScroll: true, + avatar: "Avatar", + }, + { + name: "Tab 3", + href: "?path=/story/ui-navigation-horizontaltabs--horizontal-tabs/tab3", + disabled: true, + linkShallow: true, + linkScroll: true, + }, +]; + +<CustomArgsTable of={HorizontalTabs} /> + +<Examples title="Default"> + <Example title="Default"> + <HorizontalTabs + tabs={[ + { + name: "tab 1", + href: "?path=/story/ui-navigation-horizontaltabs--horizontal-tabs/tab1", + }, + ]} + /> + </Example> + <Example title="With avatar"> + <HorizontalTabs + tabs={[ + { + name: "Tab 1", + href: "?path=/story/ui-navigation-horizontaltabs--horizontal-tabs/tab1", + avatar: "Avatar", + }, + ]} + /> + </Example> + <Example title="With icon"> + <HorizontalTabs + tabs={[ + { + name: "Tab 1", + href: "?path=/story/ui-navigation-horizontaltabs--horizontal-tabs/tab1", + icon: Plus, + }, + ]} + /> + </Example> + <Example title="Disabled"> + <HorizontalTabs + tabs={[ + { + name: "Tab 1", + href: "?path=/story/ui-navigation-horizontaltabs--horizontal-tabs/tab1", + disabled: true, + }, + ]} + /> + </Example> +</Examples> + +## HorizontalTabs Story + +<Canvas> + <Story + name="Horizontal Tabs" + args={{ + name: "Tab 1", + href: "/tab1", + disabled: false, + className: "", + linkShallow: true, + linkScroll: true, + icon: "", + avatar: "", + }} + argTypes={{ + name: { control: "text", description: "Tab name" }, + href: { control: "text", description: "Tab link" }, + disabled: { control: "boolean", description: "Whether the tab is disabled" }, + className: { control: "text", description: "Additional CSS class" }, + linkShallow: { control: "boolean", description: "Whether to use shallow links" }, + linkScroll: { control: "boolean", description: "Whether to scroll to links" }, + icon: { control: "text", description: "SVGComponent icon" }, + avatar: { control: "text", description: "Avatar image URL" }, + }}> + {(...props) => ( + <VariantsTable titles={["Default"]} columnMinWidth={150}> + <VariantRow> + <HorizontalTabs tabs={tabs} className="overflow-hidden" actions={<button>Click me</button>} /> + </VariantRow> + </VariantsTable> + )} + </Story> +</Canvas> diff --git a/packages/ui/components/navigation/tabs/__stories__/verticalTabs.stories.mdx b/packages/ui/components/navigation/tabs/__stories__/verticalTabs.stories.mdx new file mode 100644 index 0000000000..6b7b3ea2c6 --- /dev/null +++ b/packages/ui/components/navigation/tabs/__stories__/verticalTabs.stories.mdx @@ -0,0 +1,158 @@ +import { Meta, Story } from "@storybook/addon-docs/blocks"; + +import { + Title, + CustomArgsTable, + Examples, + Example, + VariantsTable, + VariantsRow, +} from "@calcom/storybook/components"; +import { Plus } from "@calcom/ui/components/icon"; + +import VerticalTabs from "../VerticalTabs"; + +<Meta title="UI/Navigation/VerticalTabs" component={VerticalTabs} /> + +<Title title="Vertical Tabs Brief" subtitle="Version 1.0 — Last Update: 17 Aug 2023" /> + +## Definition + +The VerticalTabs component is a user interface element utilized to present a vertical set of tabs, commonly employed for navigation or organizing content within a web application. + +## Structure + +The VerticalTabs component is designed to complement the HorizontalTabItem component, which represents individual tabs within the tab bar. This combination allows for creating intuitive navigation experiences and organized content presentation. + +export const tabs = [ + { + name: "Tab 1", + href: "/tab1", + disabled: false, + linkShallow: true, + linkScroll: true, + disableChevron: true, + icon: Plus, + }, + { + name: "Tab 2", + href: "/tab2", + disabled: false, + linkShallow: true, + linkScroll: true, + avatar: "Avatar", + }, + { + name: "Tab 3", + href: "/tab3", + disabled: true, + linkShallow: true, + linkScroll: true, + }, +]; + +<CustomArgsTable of={VerticalTabs} /> + +<Examples title="Default"> + <Example title="Default"> + <VerticalTabs + tabs={[ + { + name: "tab 1", + href: "/tab1", + }, + ]} + /> + </Example> + <Example title="Disabled chevron"> + <VerticalTabs + tabs={[ + { + name: "Tab 1", + href: "/tab1", + disableChevron: true, + }, + ]} + /> + </Example> + <Example title="With icon"> + <VerticalTabs + tabs={[ + { + name: "Tab 1", + href: "/tab1", + icon: Plus, + }, + ]} + /> + </Example> + <Example title="Disabled"> + <VerticalTabs + tabs={[ + { + name: "Tab 1", + href: "/tab1", + disabled: true, + }, + ]} + /> + </Example> +</Examples> + +## VerticalTabs Story + +<Canvas> + <Story + name="Vertical Tabs" + args={{ + name: "Tab 1", + info: "Tab information", + icon: Plus, + disabled: false, + children: [ + { + name: "Sub Tab 1", + href: "/sub-tab1", + disabled: false, + className: "sub-tab", + }, + ], + textClassNames: "", + className: "", + isChild: false, + hidden: false, + disableChevron: true, + href: "/tab1", + isExternalLink: true, + linkShallow: true, + linkScroll: true, + avatar: "", + iconClassName: "", + }} + argTypes={{ + name: { control: "text", description: "Tab name" }, + info: { control: "text", description: "Tab information" }, + icon: { control: "object", description: "SVGComponent icon" }, + disabled: { control: "boolean", description: "Whether the tab is disabled" }, + children: { control: "object", description: "Array of child tabs" }, + textClassNames: { control: "text", description: "Additional text class names" }, + className: { control: "text", description: "Additional CSS class" }, + isChild: { control: "boolean", description: "Whether the tab is a child tab" }, + hidden: { control: "boolean", description: "Whether the tab is hidden" }, + disableChevron: { control: "boolean", description: "Whether to disable the chevron" }, + href: { control: "text", description: "Tab link" }, + isExternalLink: { control: "boolean", description: "Whether the link is external" }, + linkShallow: { control: "boolean", description: "Whether to use shallow links" }, + linkScroll: { control: "boolean", description: "Whether to scroll to links" }, + avatar: { control: "text", description: "Avatar image URL" }, + iconClassName: { control: "text", description: "Additional icon class name" }, + }}> + {(...props) => ( + <VariantsTable titles={["Default"]} columnMinWidth={150}> + <VariantRow> + <VerticalTabs tabs={tabs} className="overflow-hidden" /> + </VariantRow> + </VariantsTable> + )} + </Story> +</Canvas> From 76d476441f12814617961e19c2f97f4b55d1b3ce Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Mon, 9 Oct 2023 14:25:14 +0530 Subject: [PATCH 006/120] fix: Dialog is open after adding a new overwrite (#11220) Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> --- apps/web/playwright/availability.e2e.ts | 1 + .../components/DateOverrideInputDialog.tsx | 83 +++++++++---------- packages/ui/components/dialog/Dialog.tsx | 2 +- 3 files changed, 42 insertions(+), 44 deletions(-) diff --git a/apps/web/playwright/availability.e2e.ts b/apps/web/playwright/availability.e2e.ts index 45cacf85ef..d065e3dbb5 100644 --- a/apps/web/playwright/availability.e2e.ts +++ b/apps/web/playwright/availability.e2e.ts @@ -28,6 +28,7 @@ test.describe("Availablity tests", () => { await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).click(); await page.locator('[data-testid="date-override-mark-unavailable"]').click(); await page.locator('[data-testid="add-override-submit-btn"]').click(); + await page.locator('[data-testid="dialog-rejection"]').click(); await expect(page.locator('[data-testid="date-overrides-list"] > li')).toHaveCount(1); await page.locator('[form="availability-form"][type="submit"]').click(); }); diff --git a/packages/features/schedules/components/DateOverrideInputDialog.tsx b/packages/features/schedules/components/DateOverrideInputDialog.tsx index e5b396f4f7..487c9d1fd3 100644 --- a/packages/features/schedules/components/DateOverrideInputDialog.tsx +++ b/packages/features/schedules/components/DateOverrideInputDialog.tsx @@ -3,7 +3,6 @@ import { useForm } from "react-hook-form"; import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; -import { classNames } from "@calcom/lib"; import { yyyymmdd } from "@calcom/lib/date-fns"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; @@ -23,15 +22,11 @@ import DatePicker from "../../calendars/DatePicker"; import type { TimeRange } from "./Schedule"; import { DayRanges } from "./Schedule"; -// eslint-disable-next-line @typescript-eslint/no-empty-function -const noop = () => {}; - const DateOverrideForm = ({ value, workingHours, excludedDates, onChange, - onClose = noop, }: { workingHours?: WorkingHours[]; onChange: (newValue: TimeRange[]) => void; @@ -137,14 +132,10 @@ const DateOverrideForm = ({ }) : datesInRanges ); - onClose(); + setSelectedDates([]); }} className="p-6 sm:flex sm:p-0 md:flex-col lg:flex-col xl:flex-row"> - <div - className={classNames( - selectedDates[0] && "sm:border-subtle w-full sm:border-r sm:pr-6", - "sm:p-4 md:p-8" - )}> + <div className="sm:border-subtle w-full sm:border-r sm:p-4 sm:pr-6 md:p-8"> <DialogHeader title={t("date_overrides_dialog_title")} /> <DatePicker excludedDates={excludedDates} @@ -160,39 +151,45 @@ const DateOverrideForm = ({ locale={isLocaleReady ? i18n.language : "en"} /> </div> - {selectedDates[0] && ( - <div className="relative mt-8 flex w-full flex-col sm:mt-0 sm:p-4 md:p-8"> - <div className="mb-4 flex-grow space-y-4"> - <p className="text-medium text-emphasis text-sm">{t("date_overrides_dialog_which_hours")}</p> - <div> - {datesUnavailable ? ( - <p className="text-subtle border-default rounded border p-2 text-sm"> - {t("date_overrides_unavailable")} - </p> - ) : ( - <DayRanges name="range" /> - )} + <div className="relative mt-8 flex w-full flex-col sm:mt-0 sm:p-4 md:p-8"> + {selectedDates[0] ? ( + <> + <div className="mb-4 flex-grow space-y-4"> + <p className="text-medium text-emphasis text-sm">{t("date_overrides_dialog_which_hours")}</p> + <div> + {datesUnavailable ? ( + <p className="text-subtle border-default rounded border p-2 text-sm"> + {t("date_overrides_unavailable")} + </p> + ) : ( + <DayRanges name="range" /> + )} + </div> + <Switch + label={t("date_overrides_mark_all_day_unavailable_one")} + checked={datesUnavailable} + onCheckedChange={setDatesUnavailable} + data-testid="date-override-mark-unavailable" + /> </div> - <Switch - label={t("date_overrides_mark_all_day_unavailable_one")} - checked={datesUnavailable} - onCheckedChange={setDatesUnavailable} - data-testid="date-override-mark-unavailable" - /> + <div className="mt-4 flex flex-row-reverse sm:mt-0"> + <Button + className="ml-2" + color="primary" + type="submit" + disabled={selectedDates.length === 0} + data-testid="add-override-submit-btn"> + {value ? t("date_overrides_update_btn") : t("date_overrides_add_btn")} + </Button> + <DialogClose /> + </div> + </> + ) : ( + <div className="bottom-7 right-8 flex flex-row-reverse sm:absolute"> + <DialogClose /> </div> - <div className="mt-4 flex flex-row-reverse sm:mt-0"> - <Button - className="ml-2" - color="primary" - type="submit" - disabled={selectedDates.length === 0} - data-testid="add-override-submit-btn"> - {value ? t("date_overrides_update_btn") : t("date_overrides_add_btn")} - </Button> - <DialogClose onClick={onClose} /> - </div> - </div> - )} + )} + </div> </Form> ); }; @@ -220,7 +217,7 @@ const DateOverrideInputDialog = ({ <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild>{Trigger}</DialogTrigger> - <DialogContent enableOverflow={enableOverflow} size="md" className="p-0 md:w-auto"> + <DialogContent enableOverflow={enableOverflow} size="md" className="p-0"> <DateOverrideForm excludedDates={excludedDates} {...passThroughProps} diff --git a/packages/ui/components/dialog/Dialog.tsx b/packages/ui/components/dialog/Dialog.tsx index 90a510c760..e78b08db9b 100644 --- a/packages/ui/components/dialog/Dialog.tsx +++ b/packages/ui/components/dialog/Dialog.tsx @@ -187,7 +187,7 @@ export function DialogClose( return ( <DialogPrimitive.Close asChild {...props.dialogCloseProps}> {/* This will require the i18n string passed in */} - <Button color={props.color || "minimal"} {...props}> + <Button data-testid="dialog-rejection" color={props.color || "minimal"} {...props}> {props.children ? props.children : t("Close")} </Button> </DialogPrimitive.Close> From d0d37ba34f8d4f1969a2206997a48ade8086aca6 Mon Sep 17 00:00:00 2001 From: Aldrin <53973174+Dhoni77@users.noreply.github.com> Date: Mon, 9 Oct 2023 16:00:47 +0530 Subject: [PATCH 007/120] docs: add an alternate approach to seed local database (#11307) Co-authored-by: Peer Richelsen <peer@cal.com> --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 1f64390a7a..0513e8acf4 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,8 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env #### Setting up your first user +##### Approach 1 + 1. Open [Prisma Studio](https://prisma.io/studio) to look at or modify the database content: ```sh @@ -264,6 +266,17 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env > New users are set on a `TRIAL` plan by default. You might want to adjust this behavior to your needs in the `packages/prisma/schema.prisma` file. 1. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user. +##### Approach 2 + +Seed the local db by running + +```sh +cd packages/prisma +yarn db-seed +``` + +The above command will populate the local db with dummy users. + ### E2E-Testing Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If you are running locally, as the documentation within `.env.example` mentions, the value should be `http://localhost:3000`. From 873a4e2000a954fc92e370b44ac67d1ca974ed7e Mon Sep 17 00:00:00 2001 From: Crowdin Bot <support+bot@crowdin.com> Date: Mon, 9 Oct 2023 10:33:51 +0000 Subject: [PATCH 008/120] New Crowdin translations by Github Action --- apps/web/public/static/locales/de/common.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/public/static/locales/de/common.json b/apps/web/public/static/locales/de/common.json index 1432448092..229d21aaf4 100644 --- a/apps/web/public/static/locales/de/common.json +++ b/apps/web/public/static/locales/de/common.json @@ -599,6 +599,7 @@ "hide_book_a_team_member": "„Ein Teammitglied buchen“-Schaltfläche ausblenden", "hide_book_a_team_member_description": "Blendet die „Ein Teammitglied buchen“-Schaltflächen auf Ihren öffentlichen Seiten aus.", "danger_zone": "Achtung", + "account_deletion_cannot_be_undone": "Vorsicht. Löschen eines Kontos kann nicht rückgängig gemacht werden.", "back": "Zurück", "cancel": "Absagen", "cancel_all_remaining": "Alle verbleibenden absagen", @@ -688,6 +689,7 @@ "people": "Personen", "your_email": "Ihre E-Mail-Adresse", "change_avatar": "Profilbild ändern", + "upload_avatar": "Avatar hochladen", "language": "Sprache", "timezone": "Zeitzone", "first_day_of_week": "Erster Tag der Woche", @@ -1530,6 +1532,7 @@ "problem_registering_domain": "Es gab ein Problem bei der Registrierung der Subdomain, bitte versuchen Sie es erneut oder kontaktieren Sie einen Administrator", "team_publish": "Team veröffentlichen", "number_text_notifications": "Telefonnummer (Textbenachrichtigungen)", + "number_sms_notifications": "Telefonnummer (SMS-Benachrichtigungen)", "attendee_email_variable": "Teilnehmer E-Mail", "attendee_email_info": "Die E-Mail-Adresse der buchenden Person", "kbar_search_placeholder": "Geben Sie einen Befehl ein oder suchen Sie ...", @@ -1639,6 +1642,7 @@ "minimum_round_robin_hosts_count": "Anzahl der Veranstalter, die teilnehmen müssen", "hosts": "Veranstalter", "upgrade_to_enable_feature": "Sie müssen ein Team erstellen, um diese Funktion zu aktivieren. Klicken Sie hier, um ein Team zu erstellen.", + "orgs_upgrade_to_enable_feature": "Sie müssen auf unsere Enterprise-Lizenz aktualisieren, um diese Funktion zu aktivieren.", "new_attendee": "Neuer Teilnehmer", "awaiting_approval": "Wartet auf Genehmigung", "requires_google_calendar": "Diese App erfordert eine Verbindung mit Google Calendar", @@ -1877,6 +1881,7 @@ "edit_invite_link": "Linkeinstellungen bearbeiten", "invite_link_copied": "Einladungslink kopiert", "invite_link_deleted": "Einladungslink gelöscht", + "api_key_deleted": "API-Schlüssel gelöscht", "invite_link_updated": "Einladungslink-Einstellungen gespeichert", "link_expires_after": "Links verfallen nach...", "one_day": "1 Tag", @@ -2011,6 +2016,8 @@ "attendee_last_name_variable": "Nachname des Teilnehmers", "attendee_first_name_info": "Vorname der buchenden Person", "attendee_last_name_info": "Nachname der buchenden Person", + "your_monthly_digest": "Ihre monatliche Statistik", + "member_name": "Mitgliedsname", "me": "Ich", "verify_team_tooltip": "Verifizieren Sie Ihr Team, um das Senden von Nachrichten an Teilnehmer zu aktivieren", "member_removed": "Mitglied entfernt", @@ -2041,10 +2048,14 @@ "team_no_event_types": "Dieses Team hat keine Ereignistypen", "seat_options_doesnt_multiple_durations": "Platzoption unterstützt mehrere Dauern nicht", "include_calendar_event": "Kalenderereignis hinzufügen", + "oAuth": "OAuth", "recently_added": "Kürzlich hinzugefügt", "no_members_found": "Keine Mitglieder gefunden", "event_setup_length_error": "Ereignis-Einrichtung: Die Dauer muss mindestens 1 Minute betragen.", "availability_schedules": "Verfügbarkeitspläne", + "unauthorized": "Nicht authorisiert", + "access_cal_account": "{{clientName}} möchte auf Ihr {{appName}} Konto zugreifen", + "select_account_team": "Konto oder Team auswählen", "view_only_edit_availability_not_onboarded": "Dieser Benutzer hat das Onboarding noch nicht abgeschlossen. Sie können seine Verfügbarkeit erst festlegen, wenn er das Onboarding abgeschlossen hat.", "view_only_edit_availability": "Sie sehen die Verfügbarkeit dieses Benutzers. Sie können nur Ihre eigene Verfügbarkeit bearbeiten.", "edit_users_availability": "Benutzerverfügbarkeit bearbeiten: {{username}}", From 16d73fab89f6843bdbd049afd5747d4007e36058 Mon Sep 17 00:00:00 2001 From: Crowdin Bot <support+bot@crowdin.com> Date: Mon, 9 Oct 2023 10:37:08 +0000 Subject: [PATCH 009/120] New Crowdin translations by Github Action --- apps/web/public/static/locales/de/common.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/public/static/locales/de/common.json b/apps/web/public/static/locales/de/common.json index 229d21aaf4..8021a06c5f 100644 --- a/apps/web/public/static/locales/de/common.json +++ b/apps/web/public/static/locales/de/common.json @@ -288,6 +288,7 @@ "when": "Wann", "where": "Wo", "add_to_calendar": "Zum Kalender hinzufügen", + "add_to_calendar_description": "Legen Sie fest, wo neue Termine hinzugefügt werden sollen, wenn Sie gebucht werden.", "add_another_calendar": "Einen weiteren Kalender hinzufügen", "other": "Sonstige", "email_sign_in_subject": "Ihr Anmelde-Link für {{appName}}", @@ -1278,6 +1279,7 @@ "personal_cal_url": "Meine persönliche {{appName}}-URL", "bio_hint": "Schreiben Sie eine kurze Beschreibung, welche auf Ihrer persönlichen Profil-Seite erscheinen wird.", "user_has_no_bio": "Dieser Benutzer hat noch keine Bio hinzugefügt.", + "bio": "Biografie", "delete_account_modal_title": "Account löschen", "confirm_delete_account_modal": "Sind Sie sicher, dass Sie Ihr {{appName}}-Konto löschen möchten?", "delete_my_account": "Meinen Account löschen", @@ -2056,8 +2058,12 @@ "unauthorized": "Nicht authorisiert", "access_cal_account": "{{clientName}} möchte auf Ihr {{appName}} Konto zugreifen", "select_account_team": "Konto oder Team auswählen", + "allow_client_to": "Dies wird {{clientName}} erlauben", "view_only_edit_availability_not_onboarded": "Dieser Benutzer hat das Onboarding noch nicht abgeschlossen. Sie können seine Verfügbarkeit erst festlegen, wenn er das Onboarding abgeschlossen hat.", "view_only_edit_availability": "Sie sehen die Verfügbarkeit dieses Benutzers. Sie können nur Ihre eigene Verfügbarkeit bearbeiten.", "edit_users_availability": "Benutzerverfügbarkeit bearbeiten: {{username}}", + "resend_invitation": "Einladung erneut senden", + "invitation_resent": "Die Einladung wurde erneut gesendet.", + "this_app_is_not_setup_already": "Diese App wurde noch nicht eingerichtet", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Fügen Sie Ihre neuen Code-Zeilen über dieser hinzu ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } From da62984b5c80a9ae1dbbf4e573ae0f342f01e15e Mon Sep 17 00:00:00 2001 From: Crowdin Bot <support+bot@crowdin.com> Date: Mon, 9 Oct 2023 10:40:23 +0000 Subject: [PATCH 010/120] New Crowdin translations by Github Action --- apps/web/public/static/locales/de/common.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/public/static/locales/de/common.json b/apps/web/public/static/locales/de/common.json index 8021a06c5f..92ddb4e4cd 100644 --- a/apps/web/public/static/locales/de/common.json +++ b/apps/web/public/static/locales/de/common.json @@ -1749,6 +1749,7 @@ "show_on_booking_page": "Auf der Buchungsseite anzeigen", "get_started_zapier_templates": "Legen Sie mit Zapier-Vorlagen los", "team_is_unpublished": "{{team}} ist unveröffentlicht", + "org_is_unpublished_description": "Dieser Organisations-Link ist derzeit nicht verfügbar. Bitte kontaktieren Sie den Organisations-Besitzer oder fragen Sie ihn, ob er ihn veröffentlicht.", "team_is_unpublished_description": "Dieser {{entity}}-Link ist derzeit nicht verfügbar. Bitte kontaktieren Sie den {{entity}}-Besitzer oder fragen Sie ihn, ob er ihn veröffentlicht.", "team_member": "Teammitglied", "a_routing_form": "Ein Weiterleitungsformular", @@ -2020,7 +2021,11 @@ "attendee_last_name_info": "Nachname der buchenden Person", "your_monthly_digest": "Ihre monatliche Statistik", "member_name": "Mitgliedsname", + "most_popular_events": "Beliebteste Termine", + "summary_of_events_for_your_team_for_the_last_30_days": "Hier ist Ihre Zusammenfassung der beliebten Termine für Ihr Team {{teamName}} der letzten 30 Tage", "me": "Ich", + "monthly_digest_email": "Monatliche Statistik E-Mail", + "monthly_digest_email_for_teams": "Monatliche Statistik E-Mail für Teams", "verify_team_tooltip": "Verifizieren Sie Ihr Team, um das Senden von Nachrichten an Teilnehmer zu aktivieren", "member_removed": "Mitglied entfernt", "my_availability": "Meine Verfügbarkeit", @@ -2059,6 +2064,7 @@ "access_cal_account": "{{clientName}} möchte auf Ihr {{appName}} Konto zugreifen", "select_account_team": "Konto oder Team auswählen", "allow_client_to": "Dies wird {{clientName}} erlauben", + "allow": "Zulassen", "view_only_edit_availability_not_onboarded": "Dieser Benutzer hat das Onboarding noch nicht abgeschlossen. Sie können seine Verfügbarkeit erst festlegen, wenn er das Onboarding abgeschlossen hat.", "view_only_edit_availability": "Sie sehen die Verfügbarkeit dieses Benutzers. Sie können nur Ihre eigene Verfügbarkeit bearbeiten.", "edit_users_availability": "Benutzerverfügbarkeit bearbeiten: {{username}}", From 0b310c0ea80d79f6b23ebb3c43ba5412f99d9ebe Mon Sep 17 00:00:00 2001 From: Crowdin Bot <support+bot@crowdin.com> Date: Mon, 9 Oct 2023 10:43:24 +0000 Subject: [PATCH 011/120] New Crowdin translations by Github Action --- apps/web/public/static/locales/de/common.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/public/static/locales/de/common.json b/apps/web/public/static/locales/de/common.json index 92ddb4e4cd..7b541cf32f 100644 --- a/apps/web/public/static/locales/de/common.json +++ b/apps/web/public/static/locales/de/common.json @@ -2064,6 +2064,10 @@ "access_cal_account": "{{clientName}} möchte auf Ihr {{appName}} Konto zugreifen", "select_account_team": "Konto oder Team auswählen", "allow_client_to": "Dies wird {{clientName}} erlauben", + "see_personal_info": "Ihre persönlichen Daten einzusehen, einschließlich persönlicher Informationen, die Sie öffentlich zugänglich gemacht haben", + "see_primary_email_address": "Ihre primäre E-Mail-Adresse einzusehen", + "connect_installed_apps": "Sich mit Ihren installierten Apps zu verbinden", + "access_event_type": "Lesen, Bearbeiten, Löschen Ihrer Ereignis-Typen", "allow": "Zulassen", "view_only_edit_availability_not_onboarded": "Dieser Benutzer hat das Onboarding noch nicht abgeschlossen. Sie können seine Verfügbarkeit erst festlegen, wenn er das Onboarding abgeschlossen hat.", "view_only_edit_availability": "Sie sehen die Verfügbarkeit dieses Benutzers. Sie können nur Ihre eigene Verfügbarkeit bearbeiten.", From a5993061f603bdaef0fa87945096cb01fe588b56 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Mon, 9 Oct 2023 16:14:11 +0530 Subject: [PATCH 012/120] chore: throw toast when adding a new date overwrite (#11764) Co-authored-by: Peer Richelsen <peer@cal.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> --- apps/web/public/static/locales/en/common.json | 1 + .../features/schedules/components/DateOverrideInputDialog.tsx | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 16b1cb13a4..db4ff60a2d 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1619,6 +1619,7 @@ "date_overrides_mark_all_day_unavailable_other": "Mark unavailable on selected dates", "date_overrides_add_btn": "Add Override", "date_overrides_update_btn": "Update Override", + "date_successfully_added": "Date override added successfully", "event_type_duplicate_copy_text": "{{slug}}-copy", "set_as_default": "Set as default", "hide_eventtype_details": "Hide event type details", diff --git a/packages/features/schedules/components/DateOverrideInputDialog.tsx b/packages/features/schedules/components/DateOverrideInputDialog.tsx index 487c9d1fd3..2ae8891300 100644 --- a/packages/features/schedules/components/DateOverrideInputDialog.tsx +++ b/packages/features/schedules/components/DateOverrideInputDialog.tsx @@ -14,6 +14,7 @@ import { DialogHeader, DialogClose, Switch, + showToast, Form, Button, } from "@calcom/ui"; @@ -177,6 +178,9 @@ const DateOverrideForm = ({ className="ml-2" color="primary" type="submit" + onClick={() => { + showToast(t("date_successfully_added"), "success"); + }} disabled={selectedDates.length === 0} data-testid="add-override-submit-btn"> {value ? t("date_overrides_update_btn") : t("date_overrides_add_btn")} From 018b0e0543f6257bcfaff382b27857757dec9bea Mon Sep 17 00:00:00 2001 From: Crowdin Bot <support+bot@crowdin.com> Date: Mon, 9 Oct 2023 10:47:47 +0000 Subject: [PATCH 013/120] New Crowdin translations by Github Action --- apps/web/public/static/locales/de/common.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/public/static/locales/de/common.json b/apps/web/public/static/locales/de/common.json index 7b541cf32f..110b1a124b 100644 --- a/apps/web/public/static/locales/de/common.json +++ b/apps/web/public/static/locales/de/common.json @@ -2068,6 +2068,9 @@ "see_primary_email_address": "Ihre primäre E-Mail-Adresse einzusehen", "connect_installed_apps": "Sich mit Ihren installierten Apps zu verbinden", "access_event_type": "Lesen, Bearbeiten, Löschen Ihrer Ereignis-Typen", + "access_availability": "Lesen, Bearbeiten, Löschen Ihrer Verfügbarkeiten", + "access_bookings": "Lesen, Bearbeiten, Löschen Ihrer Termine", + "allow_client_to_do": "{{clientName}} zulassen, dies zu tun?", "allow": "Zulassen", "view_only_edit_availability_not_onboarded": "Dieser Benutzer hat das Onboarding noch nicht abgeschlossen. Sie können seine Verfügbarkeit erst festlegen, wenn er das Onboarding abgeschlossen hat.", "view_only_edit_availability": "Sie sehen die Verfügbarkeit dieses Benutzers. Sie können nur Ihre eigene Verfügbarkeit bearbeiten.", From d8f6400add8d941ce31c9f338b00a4100aa1d0eb Mon Sep 17 00:00:00 2001 From: Peer Richelsen <peeroke@gmail.com> Date: Mon, 9 Oct 2023 12:51:47 +0100 Subject: [PATCH 014/120] feat: added dark mode to login, logout, password reset (#11765) Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> --- apps/web/components/ui/AuthContainer.tsx | 4 ++-- apps/web/pages/auth/forgot-password/[id].tsx | 1 - apps/web/pages/auth/forgot-password/index.tsx | 4 ++-- apps/web/pages/auth/login.tsx | 3 +-- apps/web/pages/auth/logout.tsx | 1 - 5 files changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/web/components/ui/AuthContainer.tsx b/apps/web/components/ui/AuthContainer.tsx index 9da5b38c0f..605875bfc2 100644 --- a/apps/web/components/ui/AuthContainer.tsx +++ b/apps/web/components/ui/AuthContainer.tsx @@ -15,7 +15,7 @@ interface Props { export default function AuthContainer(props: React.PropsWithChildren<Props>) { return ( - <div className="flex min-h-screen flex-col justify-center bg-[#f3f4f6] py-12 sm:px-6 lg:px-8"> + <div className="bg-subtle dark:bg-darkgray-50 flex min-h-screen flex-col justify-center py-12 sm:px-6 lg:px-8"> <HeadSeo title={props.title} description={props.description} /> {props.showLogo && <Logo small inline={false} className="mx-auto mb-auto" />} @@ -28,7 +28,7 @@ export default function AuthContainer(props: React.PropsWithChildren<Props>) { </div> )} <div className="mb-auto mt-8 sm:mx-auto sm:w-full sm:max-w-md"> - <div className="bg-default border-subtle mx-2 rounded-md border px-4 py-10 sm:px-10"> + <div className="bg-default dark:bg-muted border-subtle mx-2 rounded-md border px-4 py-10 sm:px-10"> {props.children} </div> <div className="text-default mt-8 text-center text-sm">{props.footerText}</div> diff --git a/apps/web/pages/auth/forgot-password/[id].tsx b/apps/web/pages/auth/forgot-password/[id].tsx index 36ca1c3511..d80ef07316 100644 --- a/apps/web/pages/auth/forgot-password/[id].tsx +++ b/apps/web/pages/auth/forgot-password/[id].tsx @@ -141,7 +141,6 @@ export default function Page({ requestId, isRequestExpired, csrfToken }: Props) ); } -Page.isThemeSupported = false; Page.PageWrapper = PageWrapper; export async function getServerSideProps(context: GetServerSidePropsContext) { const id = context.params?.id as string; diff --git a/apps/web/pages/auth/forgot-password/index.tsx b/apps/web/pages/auth/forgot-password/index.tsx index 750f86b996..a6fa28b361 100644 --- a/apps/web/pages/auth/forgot-password/index.tsx +++ b/apps/web/pages/auth/forgot-password/index.tsx @@ -126,8 +126,9 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) { /> <div className="space-y-2"> <Button - className="w-full justify-center" + className="w-full justify-center dark:bg-white dark:text-black" type="submit" + color="primary" disabled={loading} aria-label={t("request_password_reset")} loading={loading}> @@ -141,7 +142,6 @@ export default function ForgotPassword({ csrfToken }: { csrfToken: string }) { ); } -ForgotPassword.isThemeSupported = false; ForgotPassword.PageWrapper = PageWrapper; export const getServerSideProps = async (context: GetServerSidePropsContext) => { diff --git a/apps/web/pages/auth/login.tsx b/apps/web/pages/auth/login.tsx index ca4e752e72..de15f84e4e 100644 --- a/apps/web/pages/auth/login.tsx +++ b/apps/web/pages/auth/login.tsx @@ -225,7 +225,7 @@ export default function Login({ type="submit" color="primary" disabled={formState.isSubmitting} - className="w-full justify-center"> + className="w-full justify-center dark:bg-white dark:text-black"> {twoFactorRequired ? t("submit") : t("sign_in")} </Button> </div> @@ -337,7 +337,6 @@ const _getServerSideProps = async function getServerSideProps(context: GetServer }; }; -Login.isThemeSupported = false; Login.PageWrapper = PageWrapper; export const getServerSideProps = withNonce(_getServerSideProps); diff --git a/apps/web/pages/auth/logout.tsx b/apps/web/pages/auth/logout.tsx index 59a259ad2a..43990d6eff 100644 --- a/apps/web/pages/auth/logout.tsx +++ b/apps/web/pages/auth/logout.tsx @@ -57,7 +57,6 @@ export function Logout(props: Props) { ); } -Logout.isThemeSupported = false; Logout.PageWrapper = PageWrapper; export default Logout; From b5cf4e50a953f24c4080e925a64dc623f334bd0f Mon Sep 17 00:00:00 2001 From: Kiran K <mailtokirankk@gmail.com> Date: Mon, 9 Oct 2023 21:00:30 +0530 Subject: [PATCH 015/120] fix: Hide SAML/OIDC login button if no sso connection exists (Self hosted instance) (#10903) Co-authored-by: zomars <zomars@me.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> --- apps/web/pages/auth/login.tsx | 17 ++++++++-- apps/web/playwright/login.oauth.e2e.ts | 7 +++-- .../server/routers/publicViewer/_router.tsx | 7 +++++ .../publicViewer/ssoConnections.handler.ts | 31 +++++++++++++++++++ 4 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 packages/trpc/server/routers/publicViewer/ssoConnections.handler.ts diff --git a/apps/web/pages/auth/login.tsx b/apps/web/pages/auth/login.tsx index de15f84e4e..a5e5168905 100644 --- a/apps/web/pages/auth/login.tsx +++ b/apps/web/pages/auth/login.tsx @@ -15,11 +15,12 @@ import { SAMLLogin } from "@calcom/features/auth/SAMLLogin"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml"; -import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; +import { WEBAPP_URL, WEBSITE_URL, HOSTED_CAL_FEATURES } from "@calcom/lib/constants"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import prisma from "@calcom/prisma"; +import { trpc } from "@calcom/trpc/react"; import { Alert, Button, EmailField, PasswordField } from "@calcom/ui"; import { ArrowLeft, Lock } from "@calcom/ui/components/icon"; @@ -160,6 +161,16 @@ export default function Login({ else setErrorMessage(errorMessages[res.error] || t("something_went_wrong")); }; + const { data, isLoading } = trpc.viewer.public.ssoConnections.useQuery(undefined, { + onError: (err) => { + setErrorMessage(err.message); + }, + }); + + const displaySSOLogin = HOSTED_CAL_FEATURES + ? true + : isSAMLLoginEnabled && !isLoading && data?.connectionExists; + return ( <div style={ @@ -232,7 +243,7 @@ export default function Login({ </form> {!twoFactorRequired && ( <> - {(isGoogleLoginEnabled || isSAMLLoginEnabled) && <hr className="border-subtle my-8" />} + {(isGoogleLoginEnabled || displaySSOLogin) && <hr className="border-subtle my-8" />} <div className="space-y-3"> {isGoogleLoginEnabled && ( <Button @@ -247,7 +258,7 @@ export default function Login({ {t("signin_with_google")} </Button> )} - {isSAMLLoginEnabled && ( + {displaySSOLogin && ( <SAMLLogin samlTenantID={samlTenantID} samlProductID={samlProductID} diff --git a/apps/web/playwright/login.oauth.e2e.ts b/apps/web/playwright/login.oauth.e2e.ts index 204b8abcf8..0c66518583 100644 --- a/apps/web/playwright/login.oauth.e2e.ts +++ b/apps/web/playwright/login.oauth.e2e.ts @@ -15,7 +15,8 @@ test("Should display SAML Login button", async ({ page }) => { // eslint-disable-next-line playwright/no-skipped-test test.skip(!IS_SAML_LOGIN_ENABLED, "It should only run if SAML Login is installed"); - await page.goto(`/auth/login`); - - await expect(page.locator(`[data-testid=saml]`)).toBeVisible(); + // TODO: Fix this later + // Button is visible only if there is a SAML connection exists (self-hosted) + // await page.goto(`/auth/login`); + // await expect(page.locator(`[data-testid=saml]`)).toBeVisible(); }); diff --git a/packages/trpc/server/routers/publicViewer/_router.tsx b/packages/trpc/server/routers/publicViewer/_router.tsx index b0d0e4cfd3..b071d304b4 100644 --- a/packages/trpc/server/routers/publicViewer/_router.tsx +++ b/packages/trpc/server/routers/publicViewer/_router.tsx @@ -49,4 +49,11 @@ export const publicViewerRouter = router({ const handler = await importHandler(namespaced("event"), () => import("./event.handler")); return handler(opts); }), + ssoConnections: publicProcedure.query(async () => { + const handler = await importHandler( + namespaced("ssoConnections"), + () => import("./ssoConnections.handler") + ); + return handler(); + }), }); diff --git a/packages/trpc/server/routers/publicViewer/ssoConnections.handler.ts b/packages/trpc/server/routers/publicViewer/ssoConnections.handler.ts new file mode 100644 index 0000000000..3acbd91053 --- /dev/null +++ b/packages/trpc/server/routers/publicViewer/ssoConnections.handler.ts @@ -0,0 +1,31 @@ +import jackson from "@calcom/features/ee/sso/lib/jackson"; +import { samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml"; +import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants"; + +import { TRPCError } from "@trpc/server"; + +export const handler = async () => { + try { + if (HOSTED_CAL_FEATURES) { + return { + connectionExists: null, + }; + } + + const { connectionController } = await jackson(); + + const connections = await connectionController.getConnections({ + tenant: samlTenantID, + product: samlProductID, + }); + + return { + connectionExists: connections.length > 0, + }; + } catch (err) { + console.error("Error getting SSO connections", err); + throw new TRPCError({ code: "BAD_REQUEST", message: "Fetching SSO connections failed." }); + } +}; + +export default handler; From b4ab710b355bbb12ccc7e9bcae55a41dccd98859 Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Date: Mon, 9 Oct 2023 21:32:15 +0400 Subject: [PATCH 016/120] add yarn lockfile section for contributors (#11769) Co-authored-by: Peer Richelsen <peeroke@gmail.com> --- CONTRIBUTING.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b477aef5f..520107e0dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -161,3 +161,48 @@ If you get errors, be sure to fix them before committing. - If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue). - Be sure to fill the PR Template accordingly. - Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations + +## Guidelines for committing yarn lockfile + +Do not commit your `yarn.lock` unless you've made changes to the `package.json`. If you've already committed `yarn.lock` unintentionally, follow these steps to undo: + +If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`: + ```bash + git checkout HEAD~1 yarn.lock + git commit -m "Revert yarn.lock changes" + ``` +If you've pushed the commit with the `yarn.lock`: + 1. Correct the commit locally using the above method. + 2. Carefully force push: + + ```bash + git push origin <your-branch-name> --force + ``` + +If `yarn.lock` was committed a while ago and there have been several commits since, you can use the following steps to revert just the `yarn.lock` changes without impacting the subsequent changes: + +1. **Checkout a Previous Version**: + - Find the commit hash before the `yarn.lock` was unintentionally committed. You can do this by viewing the Git log: + ```bash + git log yarn.lock + ``` + - Once you have identified the commit hash, use it to checkout the previous version of `yarn.lock`: + ```bash + git checkout <commit_hash> yarn.lock + ``` + +2. **Commit the Reverted Version**: + - After checking out the previous version of the `yarn.lock`, commit this change: + ```bash + git commit -m "Revert yarn.lock to its state before unintended changes" + ``` + +3. **Proceed with Caution**: + - If you need to push this change, first pull the latest changes from your remote branch to ensure you're not overwriting other recent changes: + ```bash + git pull origin <your-branch-name> + ``` + - Then push the updated branch: + ```bash + git push origin <your-branch-name> + ``` From 6c8c3de87c08ae63d04b2b4bc1049bd35eac19fe Mon Sep 17 00:00:00 2001 From: Jannes Blobel <72493222+jannesblobel@users.noreply.github.com> Date: Mon, 9 Oct 2023 19:39:04 +0200 Subject: [PATCH 017/120] fix: update inlang project settings file (#11442) --- inlang.config.js | 25 ------------------------- project.inlang.json | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 25 deletions(-) delete mode 100644 inlang.config.js create mode 100644 project.inlang.json diff --git a/inlang.config.js b/inlang.config.js deleted file mode 100644 index d35efde470..0000000000 --- a/inlang.config.js +++ /dev/null @@ -1,25 +0,0 @@ -// see https://inlang.com/ -export async function defineConfig(env) { - const { default: i18nextPlugin } = await env.$import( - "https://cdn.jsdelivr.net/npm/@inlang/plugin-i18next@2/dist/index.js" - ); - - const { default: standardLintRules } = await env.$import( - "https://cdn.jsdelivr.net/npm/@inlang/plugin-standard-lint-rules@3/dist/index.js" - ); - - return { - referenceLanguage: "en", - plugins: [ - i18nextPlugin({ - pathPattern: "./apps/web/public/static/locales/{language}/common.json", - }), - standardLintRules({ - // deactivating identical pattern because of nested - // resources like "de-DE" and "de-AT" where "de-AT" - // contrains overwrites but the majority are identical patterns. - identicalPattern: "off", - }), - ], - }; -} diff --git a/project.inlang.json b/project.inlang.json new file mode 100644 index 0000000000..fefd597160 --- /dev/null +++ b/project.inlang.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "sourceLanguageTag": "en", + "languageTags": ["ar", "az", "bg", "ca", "cs", "da", "de", "el", "en", "es-419", "es", "eu", "fr", "he", "hr", "hu", "id", "it", "iw", "ja", "ko", "nl", "no", "pl", "pt-BR", "pt", "ro", "ru", "sk", "sr", "sv", "ta", "tr", "uk", "vi", "zh-CN", "zh-TW"], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-i18next@4/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-empty-pattern@1/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-identical-pattern@1/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-without-source@1/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@1/dist/index.js" + ], + "plugin.inlang.i18next": { + "pathPattern": "./apps/web/public/static/locales/{languageTag}/common.json" + }, + "plugin.inlang.standardLintRules": { + "identicalPattern": "off" + } +} From a53ea33168d75f377cc7841ea9a559b73a10a9ed Mon Sep 17 00:00:00 2001 From: zomars <zomars@me.com> Date: Mon, 9 Oct 2023 19:55:54 -0700 Subject: [PATCH 018/120] fix: API build --- apps/api/pages/api/event-types/[id]/_patch.ts | 2 ++ apps/api/pages/api/event-types/_post.ts | 2 ++ apps/api/pages/api/event-types/_utils/checkUserMembership.ts | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/api/pages/api/event-types/[id]/_patch.ts b/apps/api/pages/api/event-types/[id]/_patch.ts index f70bd28407..7c8fcd480a 100644 --- a/apps/api/pages/api/event-types/[id]/_patch.ts +++ b/apps/api/pages/api/event-types/[id]/_patch.ts @@ -209,6 +209,8 @@ export async function patchHandler(req: NextApiRequest) { hosts = [], bookingLimits, durationLimits, + /** FIXME: Updating event-type children from API not supported for now */ + children: _, ...parsedBody } = schemaEventTypeEditBodyParams.parse(body); diff --git a/apps/api/pages/api/event-types/_post.ts b/apps/api/pages/api/event-types/_post.ts index 075ed4c71a..f81af6171a 100644 --- a/apps/api/pages/api/event-types/_post.ts +++ b/apps/api/pages/api/event-types/_post.ts @@ -268,6 +268,8 @@ async function postHandler(req: NextApiRequest) { hosts = [], bookingLimits, durationLimits, + /** FIXME: Adding event-type children from API not supported for now */ + children: _, ...parsedBody } = schemaEventTypeCreateBodyParams.parse(body || {}); diff --git a/apps/api/pages/api/event-types/_utils/checkUserMembership.ts b/apps/api/pages/api/event-types/_utils/checkUserMembership.ts index df819bc95e..ad449b42b3 100644 --- a/apps/api/pages/api/event-types/_utils/checkUserMembership.ts +++ b/apps/api/pages/api/event-types/_utils/checkUserMembership.ts @@ -11,7 +11,7 @@ import { HttpError } from "@calcom/lib/http-error"; * if the event type doesn't belong to any team, * or if the user isn't a member of the associated team. */ -export default async function checkUserMembership(parentId: number, userId: number) { +export default async function checkUserMembership(parentId: number, userId?: number) { const parentEventType = await prisma.eventType.findUnique({ where: { id: parentId, From 1456e2d4d57af6812a73401441b9f8313b195292 Mon Sep 17 00:00:00 2001 From: Hariom Balhara <hariombalhara@gmail.com> Date: Tue, 10 Oct 2023 08:40:04 +0530 Subject: [PATCH 019/120] feat: Embed - Introduce `prerender` instruction - Lightning fast popup experience (#11303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Omar López <zomars@me.com> --- apps/web/playwright/fixtures/embeds.ts | 141 ++++++------ apps/web/playwright/lib/fixtures.ts | 14 +- apps/web/playwright/lib/testUtils.ts | 4 +- packages/embeds/.eslintrc.js | 1 + packages/embeds/embed-core/index.html | 19 +- packages/embeds/embed-core/playground.ts | 31 ++- .../embed-core/playwright/lib/testUtils.ts | 10 +- .../playwright/tests/action-based.e2e.ts | 93 +++++--- .../embed-core/playwright/tests/inline.e2e.ts | 3 +- .../embed-core/src/ModalBox/ModalBox.ts | 27 ++- .../embeds/embed-core/src/embed-iframe.ts | 205 +++++++++++++----- packages/embeds/embed-core/src/embed.ts | 192 +++++++++++----- .../embed-react/playwright/tests/basic.e2e.ts | 3 +- playwright.config.ts | 37 +++- 14 files changed, 543 insertions(+), 237 deletions(-) diff --git a/apps/web/playwright/fixtures/embeds.ts b/apps/web/playwright/fixtures/embeds.ts index 35e33ae21a..5c867dcfbe 100644 --- a/apps/web/playwright/fixtures/embeds.ts +++ b/apps/web/playwright/fixtures/embeds.ts @@ -1,81 +1,90 @@ import type { Page } from "@playwright/test"; export const createEmbedsFixture = (page: Page) => { - return async (calNamespace: string) => { - await page.addInitScript( - ({ calNamespace }: { calNamespace: string }) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - window.eventsFiredStoreForPlaywright = window.eventsFiredStoreForPlaywright || {}; - document.addEventListener("DOMContentLoaded", function tryAddingListener() { - if (parent !== window) { - // Firefox seems to execute this snippet for iframe as well. Avoid that. It must be executed only for parent frame. - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - window.initialBodyVisibility = document.body.style.visibility; - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - window.initialBodyBackground = document.body.style.background; - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - window.initialValuesSet = true; - - return; - } - + return { + /** + * @deprecated Use gotoPlayground instead + */ + async addEmbedListeners(calNamespace: string) { + await page.addInitScript( + ({ calNamespace }: { calNamespace: string }) => { + console.log("PlaywrightTest:", "Adding listener for __iframeReady on namespace:", calNamespace); // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore - let api = window.Cal; + window.eventsFiredStoreForPlaywright = window.eventsFiredStoreForPlaywright || {}; + document.addEventListener("DOMContentLoaded", function tryAddingListener() { + if (parent !== window) { + // Firefox seems to execute this snippet for iframe as well. Avoid that. It must be executed only for parent frame. + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + window.initialBodyVisibility = document.body.style.visibility; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + window.initialBodyBackground = document.body.style.background; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + window.initialValuesSet = true; + return; + } - if (!api) { - setTimeout(tryAddingListener, 500); - return; - } - if (calNamespace) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore - api = window.Cal.ns[calNamespace]; - } - console.log("PlaywrightTest:", "Adding listener for __iframeReady"); - if (!api) { - throw new Error(`namespace "${calNamespace}" not found`); - } - api("on", { - action: "*", - callback: (e) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - window.iframeReady = true; // Technically if there are multiple cal embeds, it can be set due to some other iframe. But it works for now. Improve it when it doesn't work + let api = window.Cal; + if (!api) { + console.log("PlaywrightTest:", "window.Cal not available yet, trying again"); + setTimeout(tryAddingListener, 500); + return; + } + if (calNamespace) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const store = window.eventsFiredStoreForPlaywright; - const eventStore = (store[`${e.detail.type}-${e.detail.namespace}`] = - store[`${e.detail.type}-${e.detail.namespace}`] || []); - eventStore.push(e.detail); - }, + //@ts-ignore + api = window.Cal.ns[calNamespace]; + } + console.log("PlaywrightTest:", `Adding listener for __iframeReady on namespace:${calNamespace}`); + if (!api) { + throw new Error(`namespace "${calNamespace}" not found`); + } + api("on", { + action: "*", + callback: (e) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + window.iframeReady = true; // Technically if there are multiple cal embeds, it can be set due to some other iframe. But it works for now. Improve it when it doesn't work + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const store = window.eventsFiredStoreForPlaywright; + const eventStore = (store[`${e.detail.type}-${e.detail.namespace}`] = + store[`${e.detail.type}-${e.detail.namespace}`] || []); + eventStore.push(e.detail); + }, + }); }); - }); - }, - { calNamespace } - ); - }; -}; - -export const createGetActionFiredDetails = (page: Page) => { - return async ({ calNamespace, actionType }: { calNamespace: string; actionType: string }) => { - if (!page.isClosed()) { - return await page.evaluate( - ({ actionType, calNamespace }) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - return window.eventsFiredStoreForPlaywright[`${actionType}-${calNamespace}`]; }, - { actionType, calNamespace } + { calNamespace } ); - } + }, + + async getActionFiredDetails({ calNamespace, actionType }: { calNamespace: string; actionType: string }) { + if (!page.isClosed()) { + return await page.evaluate( + ({ actionType, calNamespace }) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + return window.eventsFiredStoreForPlaywright[`${actionType}-${calNamespace}`]; + }, + { actionType, calNamespace } + ); + } + }, + + async gotoPlayground({ calNamespace, url }: { calNamespace: string; url: string }) { + await this.addEmbedListeners(calNamespace); + await page.goto(url); + }, }; }; diff --git a/apps/web/playwright/lib/fixtures.ts b/apps/web/playwright/lib/fixtures.ts index 3d8fb05490..2c9cb71216 100644 --- a/apps/web/playwright/lib/fixtures.ts +++ b/apps/web/playwright/lib/fixtures.ts @@ -8,7 +8,7 @@ import prisma from "@calcom/prisma"; import type { ExpectedUrlDetails } from "../../../../playwright.config"; import { createBookingsFixture } from "../fixtures/bookings"; -import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds"; +import { createEmbedsFixture } from "../fixtures/embeds"; import { createPaymentsFixture } from "../fixtures/payments"; import { createRoutingFormsFixture } from "../fixtures/routingForms"; import { createServersFixture } from "../fixtures/servers"; @@ -19,8 +19,7 @@ export interface Fixtures { users: ReturnType<typeof createUsersFixture>; bookings: ReturnType<typeof createBookingsFixture>; payments: ReturnType<typeof createPaymentsFixture>; - addEmbedListeners: ReturnType<typeof createEmbedsFixture>; - getActionFiredDetails: ReturnType<typeof createGetActionFiredDetails>; + embeds: ReturnType<typeof createEmbedsFixture>; servers: ReturnType<typeof createServersFixture>; prisma: typeof prisma; emails?: API; @@ -36,7 +35,8 @@ declare global { calNamespace: string, // eslint-disable-next-line getActionFiredDetails: (a: { calNamespace: string; actionType: string }) => Promise<any>, - expectedUrlDetails?: ExpectedUrlDetails + expectedUrlDetails?: ExpectedUrlDetails, + isPrendered?: boolean ): Promise<R>; } } @@ -58,14 +58,10 @@ export const test = base.extend<Fixtures>({ const payemntsFixture = createPaymentsFixture(page); await use(payemntsFixture); }, - addEmbedListeners: async ({ page }, use) => { + embeds: async ({ page }, use) => { const embedsFixture = createEmbedsFixture(page); await use(embedsFixture); }, - getActionFiredDetails: async ({ page }, use) => { - const getActionFiredDetailsFixture = createGetActionFiredDetails(page); - await use(getActionFiredDetailsFixture); - }, servers: async ({}, use) => { const servers = createServersFixture(); await use(servers); diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index 7279d39d8f..f401dca0f9 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -1,4 +1,4 @@ -import type { Page } from "@playwright/test"; +import type { Frame, Page } from "@playwright/test"; import { expect } from "@playwright/test"; import type { IncomingMessage, ServerResponse } from "http"; import { createServer } from "http"; @@ -86,7 +86,7 @@ export async function waitFor(fn: () => Promise<unknown> | unknown, opts: { time } } -export async function selectFirstAvailableTimeSlotNextMonth(page: Page) { +export async function selectFirstAvailableTimeSlotNextMonth(page: Page | Frame) { // Let current month dates fully render. await page.click('[data-testid="incrementMonth"]'); diff --git a/packages/embeds/.eslintrc.js b/packages/embeds/.eslintrc.js index 59165894d4..62e45fcb34 100644 --- a/packages/embeds/.eslintrc.js +++ b/packages/embeds/.eslintrc.js @@ -1,3 +1,4 @@ +/** @type {import("eslint").Linter.Config} */ module.exports = { extends: ["../../.eslintrc.js"], rules: { diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html index d62d51ee2a..c4c28ca0ce 100644 --- a/packages/embeds/embed-core/index.html +++ b/packages/embeds/embed-core/index.html @@ -85,20 +85,30 @@ <span style="display: block"><a href="?color-scheme=dark">With Dark Color Scheme for the Page</a></span> <span style="display: block"><a href="?nonResponsive">Non responsive version of this page here</a></span> <span style="display: block" - ><a href="?only=prerender-test">Go to Pre-render test page only</a><small></small + ><a href="?only=prerender-test">Go to Prerender test page only</a><small></small ></span> + <span style="display: block" + ><a href="?only=preload-test">Go to Preload test page only</a><small></small + ></span> <button onclick="document.documentElement.style.colorScheme='dark'">Toggle Dark Scheme</button> <button onclick="document.documentElement.style.colorScheme='light'">Toggle Light Scheme</button> <div> <script> if (only === "all" || only === "prerender-test") { document.write(` - <button data-cal-namespace="prerendertestLightTheme" data-cal-config='{"theme":"light"}' data-cal-link="free?light&popup">Book with Free User[Light Theme]</button> + <button data-cal-namespace="e2ePrerenderLightTheme" data-cal-config='{"theme":"dark", "email":"preloaded-prefilled@example.com", "name": "Preloaded Prefilled"}' data-cal-link="free/30min">Book with Free User[Dark Theme]</button> <i - >Corresponding Cal Link is being preloaded. Assuming that it would take you some time to click this + >Corresponding Cal Link is being prerendered. Assuming that it would take you some time to click this as you are reading this text, it would open up super fast[If you are running a production build on local]. Try switching to slow 3G or create a custom Network configuration which is impossibly - slow</i + slow. This should be used if you know beforehand which type of embed is going to be opened.</i + >`); + } + if (only === "all" || only === "preload-test") { + document.write(` + <button data-cal-namespace="preloadTest" data-cal-config='{"theme":"dark", "email":"preloaded-prefilled@example.com", "name": "Preloaded Prefilled"}' data-cal-link="free/30min">Book with Free User[Dark Theme]</button> + <i + >Corresponding Cal Link is being preloaded. That means that all the resources would be preloaded. This could be useful in preloading possible resources if you don't know before hand which type of embed you want to show</i >`); } </script> @@ -110,6 +120,7 @@ <a href="?only=ns:floatingButton">Floating Popup</a> <h2>Popup Examples</h2> + <button data-cal-namespace="e2ePopupLightTheme" data-cal-link="free" data-cal-config='{"theme":"light"}'>Book an event with Free[Light Theme]</button> <button data-cal-namespace="popupAutoTheme" data-cal-link="free"> Book with Free User[Auto Theme] </button> diff --git a/packages/embeds/embed-core/playground.ts b/packages/embeds/embed-core/playground.ts index 6f66de976f..a9776cf934 100644 --- a/packages/embeds/embed-core/playground.ts +++ b/packages/embeds/embed-core/playground.ts @@ -24,7 +24,7 @@ document.addEventListener("click", (e) => { const searchParams = new URL(document.URL).searchParams; const only = searchParams.get("only"); const colorScheme = searchParams.get("color-scheme"); - +const prerender = searchParams.get("prerender"); if (colorScheme) { document.documentElement.style.colorScheme = colorScheme; } @@ -211,13 +211,25 @@ if (only === "all" || only === "ns:fifth") { callback, }); } + if (only === "all" || only === "prerender-test") { - Cal("init", "prerendertestLightTheme", { + Cal("init", "e2ePrerenderLightTheme", { debug: true, origin: "http://localhost:3000", }); - Cal.ns.prerendertestLightTheme("preload", { - calLink: "free", + Cal.ns.e2ePrerenderLightTheme("prerender", { + calLink: "free/30min", + type: "modal", + }); +} + +if (only === "all" || only === "preload-test") { + Cal("init", "preloadTest", { + debug: true, + origin: "http://localhost:3000", + }); + Cal.ns.preloadTest("preload", { + calLink: "free/30min", }); } @@ -300,6 +312,11 @@ Cal("init", "popupDarkTheme", { origin: "http://localhost:3000", }); +Cal("init", "e2ePopupLightTheme", { + debug: true, + origin: "http://localhost:3000", +}); + Cal("init", "popupHideEventTypeDetails", { debug: true, origin: "http://localhost:3000", @@ -360,6 +377,12 @@ Cal("init", "routingFormDark", { }); if (only === "all" || only == "ns:floatingButton") { + if (prerender == "true") { + Cal.ns.floatingButton("prerender", { + calLink: calLink || "pro", + type: "floatingButton", + }); + } Cal.ns.floatingButton("floatingButton", { calLink: calLink || "pro", config: { diff --git a/packages/embeds/embed-core/playwright/lib/testUtils.ts b/packages/embeds/embed-core/playwright/lib/testUtils.ts index 41927b7666..23a5fc996c 100644 --- a/packages/embeds/embed-core/playwright/lib/testUtils.ts +++ b/packages/embeds/embed-core/playwright/lib/testUtils.ts @@ -56,9 +56,13 @@ export const getEmbedIframe = async ({ clearInterval(interval); resolve(true); } else { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - console.log("Iframe Status:", !!iframe, !!iframe?.contentWindow, window.iframeReady); + console.log("Waiting for all three to be true:", { + iframeElement: iframe, + contentWindow: iframe?.contentWindow, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + iframeReady: window.iframeReady, + }); } }, 500); diff --git a/packages/embeds/embed-core/playwright/tests/action-based.e2e.ts b/packages/embeds/embed-core/playwright/tests/action-based.e2e.ts index 223b80d81e..7bf0e5fa3c 100644 --- a/packages/embeds/embed-core/playwright/tests/action-based.e2e.ts +++ b/packages/embeds/embed-core/playwright/tests/action-based.e2e.ts @@ -3,6 +3,7 @@ import { expect } from "@playwright/test"; import { test } from "@calcom/web/playwright/lib/fixtures"; import type { Fixtures } from "@calcom/web/playwright/lib/fixtures"; +import { selectFirstAvailableTimeSlotNextMonth } from "@calcom/web/playwright/lib/testUtils"; import { todo, @@ -18,9 +19,9 @@ async function bookFirstFreeUserEventThroughEmbed({ page, getActionFiredDetails, }: { - addEmbedListeners: Fixtures["addEmbedListeners"]; + addEmbedListeners: Fixtures["embeds"]["addEmbedListeners"]; page: Page; - getActionFiredDetails: Fixtures["getActionFiredDetails"]; + getActionFiredDetails: Fixtures["embeds"]["getActionFiredDetails"]; }) { const embedButtonLocator = page.locator('[data-cal-link="free"]').first(); await page.goto("/"); @@ -50,24 +51,16 @@ test.describe("Popup Tests", () => { await deleteAllBookingsByEmail("embed-user@example.com"); }); - test("should open embed iframe on click - Configured with light theme", async ({ - page, - addEmbedListeners, - getActionFiredDetails, - }) => { + test("should open embed iframe on click - Configured with light theme", async ({ page, embeds }) => { await deleteAllBookingsByEmail("embed-user@example.com"); + const calNamespace = "e2ePopupLightTheme"; + await embeds.gotoPlayground({ calNamespace, url: "/" }); - const calNamespace = "prerendertestLightTheme"; - await addEmbedListeners(calNamespace); - await page.goto("/?only=prerender-test"); - let embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" }); - expect(embedIframe).toBeFalsy(); + await page.click(`[data-cal-namespace="${calNamespace}"]`); - await page.click('[data-cal-link="free?light&popup"]'); + const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" }); - embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" }); - - await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, { + await expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, { pathname: "/free", }); // expect(await page.screenshot()).toMatchSnapshot("event-types-list.png"); @@ -82,7 +75,10 @@ test.describe("Popup Tests", () => { await deleteAllBookingsByEmail("embed-user@example.com"); }); - test("should be able to reschedule", async ({ page, addEmbedListeners, getActionFiredDetails }) => { + test("should be able to reschedule", async ({ + page, + embeds: { addEmbedListeners, getActionFiredDetails }, + }) => { const booking = await test.step("Create a booking", async () => { return await bookFirstFreeUserEventThroughEmbed({ page, @@ -108,8 +104,7 @@ test.describe("Popup Tests", () => { test("should open Routing Forms embed on click", async ({ page, - addEmbedListeners, - getActionFiredDetails, + embeds: { addEmbedListeners, getActionFiredDetails }, }) => { await deleteAllBookingsByEmail("embed-user@example.com"); @@ -143,8 +138,7 @@ test.describe("Popup Tests", () => { test.describe("Pro User - Configured in App with default setting of system theme", () => { test("should open embed iframe according to system theme when no theme is configured through Embed API", async ({ page, - addEmbedListeners, - getActionFiredDetails, + embeds: { addEmbedListeners, getActionFiredDetails }, }) => { const calNamespace = "floatingButton"; await addEmbedListeners(calNamespace); @@ -175,8 +169,7 @@ test.describe("Popup Tests", () => { test("should open embed iframe according to system theme when configured with 'auto' theme using Embed API", async ({ page, - addEmbedListeners, - getActionFiredDetails, + embeds: { addEmbedListeners, getActionFiredDetails }, }) => { const calNamespace = "floatingButton"; await addEmbedListeners(calNamespace); @@ -203,8 +196,7 @@ test.describe("Popup Tests", () => { test("should open embed iframe(Booker Profile Page) with dark theme when configured with dark theme using Embed API", async ({ page, - addEmbedListeners, - getActionFiredDetails, + embeds: { addEmbedListeners, getActionFiredDetails }, }) => { const calNamespace = "floatingButton"; await addEmbedListeners(calNamespace); @@ -227,8 +219,7 @@ test.describe("Popup Tests", () => { test("should open embed iframe(Event Booking Page) with dark theme when configured with dark theme using Embed API", async ({ page, - addEmbedListeners, - getActionFiredDetails, + embeds: { addEmbedListeners, getActionFiredDetails }, }) => { const calNamespace = "floatingButton"; await addEmbedListeners(calNamespace); @@ -250,4 +241,52 @@ test.describe("Popup Tests", () => { }); }); }); + + test("prendered embed should be loaded and apply the config given to it", async ({ page, embeds }) => { + const calNamespace = "e2ePrerenderLightTheme"; + const calLink = "/free/30min"; + await embeds.gotoPlayground({ calNamespace, url: "/?only=prerender-test" }); + await expectPrerenderedIframe({ calNamespace, calLink, embeds, page }); + + await page.click(`[data-cal-namespace="${calNamespace}"]`); + + const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: calLink }); + // eslint-disable-next-line playwright/no-conditional-in-test + if (!embedIframe) { + throw new Error("Embed iframe not found"); + } + await selectFirstAvailableTimeSlotNextMonth(embedIframe); + await expect(embedIframe.locator('[name="name"]')).toHaveValue("Preloaded Prefilled"); + await expect(embedIframe.locator('[name="email"]')).toHaveValue("preloaded-prefilled@example.com"); + + await expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, { + pathname: calLink, + }); + }); }); + +async function expectPrerenderedIframe({ + page, + calNamespace, + calLink, + embeds, +}: { + page: Page; + calNamespace: string; + calLink: string; + embeds: Fixtures["embeds"]; +}) { + const prerenderedIframe = await getEmbedIframe({ calNamespace, page, pathname: calLink }); + + if (!prerenderedIframe) { + throw new Error("Prerendered iframe not found"); + } + await expect(prerenderedIframe).toBeEmbedCalLink( + calNamespace, + embeds.getActionFiredDetails, + { + pathname: calLink, + }, + true + ); +} diff --git a/packages/embeds/embed-core/playwright/tests/inline.e2e.ts b/packages/embeds/embed-core/playwright/tests/inline.e2e.ts index 6109aeb53b..7c32ceefaf 100644 --- a/packages/embeds/embed-core/playwright/tests/inline.e2e.ts +++ b/packages/embeds/embed-core/playwright/tests/inline.e2e.ts @@ -7,8 +7,7 @@ import { bookFirstEvent, deleteAllBookingsByEmail, getEmbedIframe, todo } from " test.describe("Inline Iframe", () => { test("Inline Iframe - Configured with Dark Theme", async ({ page, - getActionFiredDetails, - addEmbedListeners, + embeds: { addEmbedListeners, getActionFiredDetails }, }) => { await deleteAllBookingsByEmail("embed-user@example.com"); await addEmbedListeners(""); diff --git a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts index 57a6dd5da9..f462fef425 100644 --- a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts +++ b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts @@ -41,6 +41,21 @@ export class ModalBox extends HTMLElement { this.dispatchEvent(event); } + hideIframe() { + const iframe = this.querySelector("iframe"); + if (iframe) { + iframe.style.visibility = "hidden"; + } + } + + showIframe() { + const iframe = this.querySelector("iframe"); + if (iframe) { + // Don't use visibility visible as that will make the iframe visible even when the modal is closed + iframe.style.visibility = ""; + } + } + getLoaderElement() { this.assertHasShadowRoot(); const loaderEl = this.shadowRoot.querySelector<HTMLElement>(".loader"); @@ -68,10 +83,14 @@ export class ModalBox extends HTMLElement { return; } - if (newValue == "loaded") { - this.getLoaderElement().style.display = "none"; - } else if (newValue === "started") { + if (newValue === "loading") { this.open(); + this.hideIframe(); + this.getLoaderElement().style.display = "block"; + } else if (newValue == "loaded" || newValue === "reopening") { + this.open(); + this.showIframe(); + this.getLoaderElement().style.display = "none"; } else if (newValue == "closed") { this.close(); } else if (newValue === "failed") { @@ -79,6 +98,8 @@ export class ModalBox extends HTMLElement { this.getErrorElement().style.display = "inline-block"; const errorString = getErrorString(this.dataset.errorCode); this.getErrorElement().innerText = errorString; + } else if (newValue === "prerendering") { + this.close(); } } diff --git a/packages/embeds/embed-core/src/embed-iframe.ts b/packages/embeds/embed-core/src/embed-iframe.ts index 7671baf5b1..bb5cb8c655 100644 --- a/packages/embeds/embed-core/src/embed-iframe.ts +++ b/packages/embeds/embed-core/src/embed-iframe.ts @@ -1,3 +1,4 @@ +import { useRouter } from "next/navigation"; import { useSearchParams } from "next/navigation"; import { useEffect, useRef, useState, useCallback } from "react"; @@ -7,6 +8,29 @@ import type { EmbedThemeConfig, UiConfig, EmbedNonStylesConfig, BookerLayouts, E type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>; type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>; +const enum EMBED_IFRAME_STATE { + NOT_INITIALIZED, + INITIALIZED, +} +/** + * All types of config that are critical to be processed as soon as possible are provided as query params to the iframe + */ +export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & { + // TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app. + iframeAttrs?: Record<string, string> & { + id?: string; + }; + + // TODO: It should have a dedicated prefill prop + // prefill: {}, + + // TODO: Move layout and theme as nested props of ui as it makes it clear that these two can be configured using `ui` instruction as well any time. + // ui: {layout; theme} + layout?: BookerLayouts; + // TODO: Rename layout and theme as ui.layout and ui.theme as it makes it clear that these two can be configured using `ui` instruction as well any time. + "ui.color-scheme"?: string; + theme?: EmbedThemeConfig; +}; declare global { interface Window { @@ -17,10 +41,34 @@ declare global { }; } } + /** * This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost. */ const embedStore = { + // Handles the commands of routing received from parent even when React hasn't initialized and nextRouter isn't available + router: { + setNextRouter(nextRouter: ReturnType<typeof useRouter>) { + this.nextRouter = nextRouter; + + // Empty the queue after running push on nextRouter. This is important because setNextRouter is be called multiple times + this.queue.forEach((url) => { + nextRouter.push(url); + this.queue.splice(0, 1); + }); + }, + nextRouter: null as null | ReturnType<typeof useRouter>, + queue: [] as string[], + goto(url: string) { + if (this.nextRouter) { + this.nextRouter.push(url.toString()); + } else { + this.queue.push(url); + } + }, + }, + + state: EMBED_IFRAME_STATE.NOT_INITIALIZED, // Store all embed styles here so that as and when new elements are mounted, styles can be applied to it. styles: {} as EmbedStyles | undefined, nonStyles: {} as EmbedNonStylesConfig | undefined, @@ -148,6 +196,8 @@ const useUrlChange = (callback: (newUrl: string) => void) => { const pathname = currentFullUrl?.pathname ?? ""; const searchParams = currentFullUrl?.searchParams ?? null; const lastKnownUrl = useRef(`${pathname}?${searchParams}`); + const router = useRouter(); + embedStore.router.setNextRouter(router); useEffect(() => { const newUrl = `${pathname}?${searchParams}`; if (lastKnownUrl.current !== newUrl) { @@ -340,9 +390,28 @@ const methods = { } // No UI change should happen in sight. Let the parent height adjust and in next cycle show it. unhideBody(); - sdkActionManager?.fire("linkReady", {}); + if (!isPrerendering()) { + sdkActionManager?.fire("linkReady", {}); + } }); }, + connect: function connect(queryObject: PrefillAndIframeAttrsConfig) { + const currentUrl = new URL(document.URL); + const searchParams = currentUrl.searchParams; + searchParams.delete("preload"); + for (const [key, value] of Object.entries(queryObject)) { + if (value === undefined) { + continue; + } + if (value instanceof Array) { + value.forEach((val) => searchParams.append(key, val)); + } else { + searchParams.set(key, value as string); + } + } + + connectPreloadedEmbed({ url: currentUrl }); + }, }; export type InterfaceWithParent = { @@ -451,58 +520,71 @@ if (isBrowser) { }; actOnColorScheme(embedStore.uiConfig.colorScheme); - - if (url.searchParams.get("prerender") !== "true" && window?.isEmbed?.()) { - log("Initializing embed-iframe"); - // HACK - const pageStatus = window.CalComPageStatus; - // If embed link is opened in top, and not in iframe. Let the page be visible. - if (top === window) { - unhideBody(); - } - - sdkActionManager?.on("*", (e) => { - const detail = e.detail; - log(detail); - messageParent(detail); - }); - - window.addEventListener("message", (e) => { - const data: Message = e.data; - if (!data) { - return; - } - const method: keyof typeof interfaceWithParent = data.method; - if (data.originator === "CAL" && typeof method === "string") { - interfaceWithParent[method]?.(data.arg as never); - } - }); - - document.addEventListener("click", (e) => { - if (!e.target || !(e.target instanceof Node)) { - return; - } - const mainElement = - document.getElementsByClassName("main")[0] || - document.getElementsByTagName("main")[0] || - document.documentElement; - if (e.target.contains(mainElement)) { - sdkActionManager?.fire("__closeIframe", {}); - } - }); - - if (!pageStatus || pageStatus == "200") { - keepParentInformedAboutDimensionChanges(); - sdkActionManager?.fire("__iframeReady", {}); - } else - sdkActionManager?.fire("linkFailed", { - code: pageStatus, - msg: "Problem loading the link", - data: { - url: document.URL, - }, - }); + // If embed link is opened in top, and not in iframe. Let the page be visible. + if (top === window) { + unhideBody(); } + + window.addEventListener("message", (e) => { + const data: Message = e.data; + if (!data) { + return; + } + const method: keyof typeof interfaceWithParent = data.method; + if (data.originator === "CAL" && typeof method === "string") { + interfaceWithParent[method]?.(data.arg as never); + } + }); + + document.addEventListener("click", (e) => { + if (!e.target || !(e.target instanceof Node)) { + return; + } + const mainElement = + document.getElementsByClassName("main")[0] || + document.getElementsByTagName("main")[0] || + document.documentElement; + if (e.target.contains(mainElement)) { + sdkActionManager?.fire("__closeIframe", {}); + } + }); + + sdkActionManager?.on("*", (e) => { + const detail = e.detail; + log(detail); + messageParent(detail); + }); + + if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) { + initializeAndSetupEmbed(); + } else { + log(`Preloaded scenario - Skipping initialization and setup`); + } +} + +function initializeAndSetupEmbed() { + sdkActionManager?.fire("__iframeReady", {}); + + // Only NOT_INITIALIZED -> INITIALIZED transition is allowed + if (embedStore.state !== EMBED_IFRAME_STATE.NOT_INITIALIZED) { + log("Embed Iframe already initialized"); + return; + } + embedStore.state = EMBED_IFRAME_STATE.INITIALIZED; + log("Initializing embed-iframe"); + // HACK + const pageStatus = window.CalComPageStatus; + + if (!pageStatus || pageStatus == "200") { + keepParentInformedAboutDimensionChanges(); + } else + sdkActionManager?.fire("linkFailed", { + code: pageStatus, + msg: "Problem loading the link", + data: { + url: document.URL, + }, + }); } function runAllUiSetters(uiConfig: UiConfig) { @@ -517,3 +599,22 @@ function actOnColorScheme(colorScheme: string | null | undefined) { } document.documentElement.style.colorScheme = colorScheme; } + +/** + * Apply configurations to the preloaded page and then ask parent to show the embed + * url has the config as params + */ +function connectPreloadedEmbed({ url }: { url: URL }) { + // TODO: Use a better way to detect that React has initialized. Currently, we are using setTimeout which is a hack. + const MAX_TIME_TO_LET_REACT_APPLY_UI_CHANGES = 700; + // It can be fired before React has initialized, so use embedStore.router(which is a nextRouter wrapper that supports a queue) + embedStore.router.goto(url.toString()); + setTimeout(() => { + // Firing this event would stop the loader and show the embed + sdkActionManager?.fire("linkReady", {}); + }, MAX_TIME_TO_LET_REACT_APPLY_UI_CHANGES); +} + +const isPrerendering = () => { + return new URL(document.URL).searchParams.get("prerender") === "true"; +}; diff --git a/packages/embeds/embed-core/src/embed.ts b/packages/embeds/embed-core/src/embed.ts index cc051bd542..df9f27e2e5 100644 --- a/packages/embeds/embed-core/src/embed.ts +++ b/packages/embeds/embed-core/src/embed.ts @@ -2,13 +2,14 @@ import { FloatingButton } from "./FloatingButton/FloatingButton"; import { Inline } from "./Inline/inline"; import { ModalBox } from "./ModalBox/ModalBox"; -import type { InterfaceWithParent, interfaceWithParent } from "./embed-iframe"; +import type { InterfaceWithParent, interfaceWithParent, PrefillAndIframeAttrsConfig } from "./embed-iframe"; import css from "./embed.css"; import { SdkActionManager } from "./sdk-action-manager"; import type { EventData, EventDataMap } from "./sdk-action-manager"; import allCss from "./tailwind.generated.css?inline"; -import type { UiConfig, EmbedThemeConfig, BookerLayouts } from "./types"; +import type { UiConfig } from "./types"; +export type { PrefillAndIframeAttrsConfig } from "./embed-iframe"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type Rest<T extends any[]> = T extends [any, ...infer U] ? U : never; export type Message = { @@ -151,34 +152,14 @@ type SingleInstruction = SingleInstructionMap[keyof SingleInstructionMap]; export type Instruction = SingleInstruction | SingleInstruction[]; export type InstructionQueue = Instruction[]; -/** - * All types of config that are critical to be processed as soon as possible are provided as query params to the iframe - */ -export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & { - // TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app. - iframeAttrs?: Record<string, string> & { - id?: string; - }; - - // TODO: It should have a dedicated prefill prop - // prefill: {}, - - // TODO: Move layout and theme as nested props of ui as it makes it clear that these two can be configured using `ui` instruction as well any time. - // ui: {layout; theme} - layout?: BookerLayouts; - // TODO: Rename layout and theme as ui.layout and ui.theme as it makes it clear that these two can be configured using `ui` instruction as well any time. - "ui.color-scheme"?: string; - theme?: EmbedThemeConfig; -}; - export class Cal { iframe?: HTMLIFrameElement; __config: Config; - modalBox!: Element; + modalBox?: Element; - inlineEl!: Element; + inlineEl?: Element; namespace: string; @@ -190,6 +171,8 @@ export class Cal { api: CalApi; + isPerendering?: boolean; + static actionsManagers: Record<Namespace, SdkActionManager>; static getQueryObject(config: PrefillAndIframeAttrsConfig) { @@ -389,6 +372,9 @@ export class Cal { }); this.actionManager.on("__routeChanged", () => { + if (!this.inlineEl) { + return; + } const { top, height } = this.inlineEl.getBoundingClientRect(); // Try to readjust and scroll into view if more than 25% is hidden. // Otherwise we assume that user might have positioned the content appropriately already @@ -398,6 +384,10 @@ export class Cal { }); this.actionManager.on("linkReady", () => { + if (this.isPerendering) { + // Absolute check to ensure that we don't mark embed as loaded if it's prerendering otherwise prerendered embed would showup without any user action + return; + } this.modalBox?.setAttribute("state", "loaded"); this.inlineEl?.setAttribute("loading", "done"); }); @@ -418,6 +408,8 @@ export class Cal { class CalApi { cal: Cal; static initializedNamespaces = [] as string[]; + modalUid?: string; + preloadedModalUid?: string; constructor(cal: Cal) { this.cal = cal; } @@ -563,41 +555,71 @@ class CalApi { modal({ calLink, config = {}, - uid, + __prerender = false, }: { calLink: string; config?: PrefillAndIframeAttrsConfig; - uid?: string | number; calOrigin?: string; + __prerender?: boolean; }) { - uid = uid || 0; + const uid = this.modalUid || this.preloadedModalUid || String(Date.now()) || "0"; + const isConnectingToPreloadedModal = this.preloadedModalUid && !this.modalUid; - const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`); - if (existingModalEl) { - existingModalEl.setAttribute("state", "started"); - return; + const containerEl = document.body; + + this.cal.isPerendering = !!__prerender; + + if (__prerender) { + // Add preload query param + config.prerender = "true"; } + + const queryObject = withColorScheme(Cal.getQueryObject(config), containerEl); + const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`); + + if (existingModalEl) { + if (isConnectingToPreloadedModal) { + this.cal.doInIframe({ + method: "connect", + arg: queryObject, + }); + this.modalUid = uid; + existingModalEl.setAttribute("state", "loading"); + return; + } else { + existingModalEl.setAttribute("state", "reopening"); + return; + } + } + + if (__prerender) { + this.preloadedModalUid = uid; + } + if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) { throw new Error("iframeAttrs should be an object"); } config.embedType = "modal"; - const containerEl = document.body; - const iframe = this.cal.createIframe({ - calLink, - queryObject: withColorScheme(Cal.getQueryObject(config), containerEl), - }); + let iframe = null; + + if (!iframe) { + iframe = this.cal.createIframe({ + calLink, + queryObject, + }); + } iframe.style.borderRadius = "8px"; - iframe.style.height = "100%"; iframe.style.width = "100%"; const template = document.createElement("template"); template.innerHTML = `<cal-modal-box uid="${uid}"></cal-modal-box>`; - this.cal.modalBox = template.content.children[0]; this.cal.modalBox.appendChild(iframe); - + if (__prerender) { + this.cal.modalBox.setAttribute("state", "prerendering"); + } this.handleClose(); containerEl.appendChild(template.content); } @@ -605,7 +627,7 @@ class CalApi { private handleClose() { // A request, to close from the iframe, should close the modal this.cal.actionManager.on("__closeIframe", () => { - this.cal.modalBox.setAttribute("state", "closed"); + this.cal.modalBox?.setAttribute("state", "closed"); }); } @@ -642,8 +664,24 @@ class CalApi { }) { this.cal.actionManager.off(action, callback); } - - preload({ calLink }: { calLink: string }) { + /** + * + * type is provided and prerenderIframe not set. We would assume prerenderIframe to be true + * type is provided and prerenderIframe set to false. We would ignore the type and preload assets only + * type is not provided and prerenderIframe set to true. We would throw error as we don't know what to prerender + * type is not provided and prerenderIframe set to false. We would preload assets only + */ + preload({ + calLink, + type, + options = {}, + }: { + calLink: string; + type?: "modal" | "floatingButton"; + options?: { + prerenderIframe?: boolean; + }; + }) { // eslint-disable-next-line prefer-rest-params validate(arguments[0], { required: true, @@ -652,17 +690,58 @@ class CalApi { type: "string", required: true, }, + type: { + type: "string", + required: false, + }, + options: { + type: Object, + required: false, + }, }, }); - const iframe = document.body.appendChild(document.createElement("iframe")); - const config = this.cal.getConfig(); + let api: GlobalCalWithoutNs = globalCal; + const namespace = this.cal.namespace; + if (namespace) { + api = globalCal.ns[namespace]; + } - const urlInstance = new URL(`${config.calOrigin}/${calLink}`); - urlInstance.searchParams.set("prerender", "true"); - iframe.src = urlInstance.toString(); - iframe.style.width = "0"; - iframe.style.height = "0"; - iframe.style.display = "none"; + if (!api) { + throw new Error(`Namespace ${namespace} isn't defined`); + } + + const config = this.cal.getConfig(); + let prerenderIframe = options.prerenderIframe; + if (type && prerenderIframe === undefined) { + prerenderIframe = true; + } + + if (!type && prerenderIframe) { + throw new Error("You should provide 'type'"); + } + + if (prerenderIframe) { + if (type === "modal" || type === "floatingButton") { + this.cal.isPerendering = true; + this.modal({ + calLink, + calOrigin: config.calOrigin, + __prerender: true, + }); + } else { + console.warn("Ignoring - full preload for inline embed and instead preloading assets only"); + preloadAssetsForCalLink({ calLink, config }); + } + } else { + preloadAssetsForCalLink({ calLink, config }); + } + } + + prerender({ calLink, type }: { calLink: string; type: "modal" | "floatingButton" }) { + this.preload({ + calLink, + type, + }); } ui(uiConfig: UiConfig) { @@ -755,7 +834,6 @@ document.addEventListener("click", (e) => { return; } - const modalUniqueId = (targetEl.dataset.uniqueId = targetEl.dataset.uniqueId || String(Date.now())); const namespace = targetEl.dataset.calNamespace; const configString = targetEl.dataset.calConfig || ""; const calOrigin = targetEl.dataset.calOrigin || ""; @@ -779,7 +857,6 @@ document.addEventListener("click", (e) => { api("modal", { calLink: path, config, - uid: modalUniqueId, calOrigin, }); }); @@ -812,3 +889,14 @@ function getEmbedApiFn(ns: string) { } return api; } + +function preloadAssetsForCalLink({ config, calLink }: { config: Config; calLink: string }) { + const iframe = document.body.appendChild(document.createElement("iframe")); + + const urlInstance = new URL(`${config.calOrigin}/${calLink}`); + urlInstance.searchParams.set("preload", "true"); + iframe.src = urlInstance.toString(); + iframe.style.width = "0"; + iframe.style.height = "0"; + iframe.style.display = "none"; +} diff --git a/packages/embeds/embed-react/playwright/tests/basic.e2e.ts b/packages/embeds/embed-react/playwright/tests/basic.e2e.ts index 8eabd97e2c..3ecffad394 100644 --- a/packages/embeds/embed-react/playwright/tests/basic.e2e.ts +++ b/packages/embeds/embed-react/playwright/tests/basic.e2e.ts @@ -6,8 +6,7 @@ import { test } from "@calcom/web/playwright/lib/fixtures"; test.describe("Inline Embed", () => { test("should verify that the iframe got created with correct URL", async ({ page, - getActionFiredDetails, - addEmbedListeners, + embeds: { getActionFiredDetails, addEmbedListeners }, }) => { //TODO: Do it with page.goto automatically await addEmbedListeners(""); diff --git a/playwright.config.ts b/playwright.config.ts index 0317a1b6f7..86a5c6593f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -159,7 +159,8 @@ expect.extend({ //TODO: Move it to testUtil, so that it doesn't need to be passed // eslint-disable-next-line getActionFiredDetails: (a: { calNamespace: string; actionType: string }) => Promise<any>, - expectedUrlDetails: ExpectedUrlDetails = {} + expectedUrlDetails: ExpectedUrlDetails = {}, + isPrerendered?: boolean ) { if (!iframe || !iframe.url) { return { @@ -169,14 +170,7 @@ expect.extend({ } const u = new URL(iframe.url()); - const frameElement = await iframe.frameElement(); - if (!(await frameElement.isVisible())) { - return { - pass: false, - message: () => `Expected iframe to be visible`, - }; - } const pathname = u.pathname; const expectedPathname = `${expectedUrlDetails.pathname}/embed`; if (expectedPathname && expectedPathname !== pathname) { @@ -206,20 +200,41 @@ expect.extend({ }; } } - let iframeReadyCheckInterval; + + const frameElement = await iframe.frameElement(); + + if (isPrerendered) { + if (await frameElement.isVisible()) { + return { + pass: false, + message: () => `Expected prerender iframe to be not visible`, + }; + } + return { + pass: true, + message: () => `is prerendered`, + }; + } + const iframeReadyEventDetail = await new Promise(async (resolve) => { - iframeReadyCheckInterval = setInterval(async () => { + const iframeReadyCheckInterval = setInterval(async () => { const iframeReadyEventDetail = await getActionFiredDetails({ calNamespace, actionType: "linkReady", }); if (iframeReadyEventDetail) { + clearInterval(iframeReadyCheckInterval); resolve(iframeReadyEventDetail); } }, 500); }); - clearInterval(iframeReadyCheckInterval); + if (!(await frameElement.isVisible())) { + return { + pass: false, + message: () => `Expected iframe to be visible`, + }; + } //At this point we know that window.initialBodyVisibility would be set as DOM would already have been ready(because linkReady event can only fire after that) const { From 2faf24fb986c7a323ee9aa1ff9387bcc14033439 Mon Sep 17 00:00:00 2001 From: Hariom Balhara <hariombalhara@gmail.com> Date: Tue, 10 Oct 2023 09:46:04 +0530 Subject: [PATCH 020/120] test: Add collective scheduling tests (#11670) --- .../utils/bookingScenario/bookingScenario.ts | 297 +++-- .../web/test/utils/bookingScenario/expects.ts | 69 +- packages/app-store/appStoreMetaData.ts | 2 +- .../app-store/getNormalizedAppMetadata.ts | 2 +- packages/app-store/utils.ts | 12 +- packages/core/EventManager.ts | 27 +- packages/core/getUserAvailability.ts | 24 +- packages/core/videoClient.ts | 6 +- .../features/bookings/lib/handleNewBooking.ts | 3 +- .../test/booking-limits.test.ts | 7 + .../test/dynamic-group-booking.test.ts | 10 + .../test/fresh-booking.test.ts} | 887 +++----------- .../test/lib/createMockNextJsRequest.ts | 7 + .../test/lib/getMockRequestDataForBooking.ts | 34 + .../test/lib/setupAndTeardown.ts | 29 + .../test/managed-event-type-booking.test.ts | 11 + .../handleNewBooking/test/reschedule.test.ts | 608 +++++++++ .../collective-scheduling.test.ts | 1086 +++++++++++++++++ packages/lib/piiFreeData.ts | 17 +- vitest.config.ts | 3 + 20 files changed, 2329 insertions(+), 812 deletions(-) create mode 100644 packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts rename packages/features/bookings/lib/{handleNewBooking.test.ts => handleNewBooking/test/fresh-booking.test.ts} (71%) create mode 100644 packages/features/bookings/lib/handleNewBooking/test/lib/createMockNextJsRequest.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/test/lib/setupAndTeardown.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/test/managed-event-type-booking.test.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index ba6b393824..1d65ff77ea 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -9,12 +9,14 @@ import { v4 as uuidv4 } from "uuid"; import "vitest-fetch-mock"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import type { getMockRequestDataForBooking } from "@calcom/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking"; import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook"; import type { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import type { SchedulingType } from "@calcom/prisma/enums"; import type { BookingStatus } from "@calcom/prisma/enums"; +import type { AppMeta } from "@calcom/types/App"; import type { NewCalendarEventType } from "@calcom/types/Calendar"; import type { EventBusyDate } from "@calcom/types/Calendar"; @@ -22,10 +24,6 @@ import { getMockPaymentService } from "./MockPaymentService"; logger.setSettings({ minLevel: "silly" }); const log = logger.getChildLogger({ prefix: ["[bookingScenario]"] }); -type App = { - slug: string; - dirName: string; -}; type InputWebhook = { appId: string | null; @@ -52,24 +50,27 @@ type ScenarioData = { /** * Prisma would return these apps */ - apps?: App[]; + apps?: Partial<AppMeta>[]; bookings?: InputBooking[]; webhooks?: InputWebhook[]; }; -type InputCredential = typeof TestData.credentials.google; +type InputCredential = typeof TestData.credentials.google & { + id?: number; +}; type InputSelectedCalendar = typeof TestData.selectedCalendars.google; -type InputUser = typeof TestData.users.example & { id: number } & { +type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & { + id: number; + defaultScheduleId?: number | null; credentials?: InputCredential[]; selectedCalendars?: InputSelectedCalendar[]; schedules: { - id: number; + // Allows giving id in the input directly so that it can be referenced somewhere else as well + id?: number; name: string; availability: { - userId: number | null; - eventTypeId: number | null; days: number[]; startTime: Date; endTime: Date; @@ -97,7 +98,8 @@ export type InputEventType = { afterEventBuffer?: number; requiresConfirmation?: boolean; destinationCalendar?: Prisma.DestinationCalendarCreateInput; -} & Partial<Omit<Prisma.EventTypeCreateInput, "users">>; + schedule?: InputUser["schedules"][number]; +} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule">>; type InputBooking = { id?: number; @@ -122,37 +124,75 @@ type InputBooking = { }[]; }; -const Timezones = { +export const Timezones = { "+5:30": "Asia/Kolkata", "+6:00": "Asia/Dhaka", }; async function addEventTypesToDb( - eventTypes: (Omit<Prisma.EventTypeCreateInput, "users" | "worflows" | "destinationCalendar"> & { + eventTypes: (Omit< + Prisma.EventTypeCreateInput, + "users" | "worflows" | "destinationCalendar" | "schedule" + > & { // eslint-disable-next-line @typescript-eslint/no-explicit-any users?: any[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any workflows?: any[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any destinationCalendar?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + schedule?: any; })[] ) { log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes)); await prismock.eventType.createMany({ data: eventTypes, }); + const allEventTypes = await prismock.eventType.findMany({ + include: { + users: true, + workflows: true, + destinationCalendar: true, + schedule: true, + }, + }); + + /** + * This is a hack to get the relationship of schedule to be established with eventType. Looks like a prismock bug that creating eventType along with schedule.create doesn't establish the relationship. + * HACK STARTS + */ + log.silly("Fixed possible prismock bug by creating schedule separately"); + for (let i = 0; i < eventTypes.length; i++) { + const eventType = eventTypes[i]; + const createdEventType = allEventTypes[i]; + + if (eventType.schedule) { + log.silly("TestData: Creating Schedule for EventType", JSON.stringify(eventType)); + await prismock.schedule.create({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + data: { + ...eventType.schedule.create, + eventType: { + connect: { + id: createdEventType.id, + }, + }, + }, + }); + } + } + /*** + * HACK ENDS + */ + log.silly( "TestData: All EventTypes in DB are", JSON.stringify({ - eventTypes: await prismock.eventType.findMany({ - include: { - users: true, - workflows: true, - destinationCalendar: true, - }, - }), + eventTypes: allEventTypes, }) ); + return allEventTypes; } async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) { @@ -197,10 +237,22 @@ async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser create: eventType.destinationCalendar, } : eventType.destinationCalendar, + schedule: eventType.schedule + ? { + create: { + ...eventType.schedule, + availability: { + createMany: { + data: eventType.schedule.availability, + }, + }, + }, + } + : eventType.schedule, }; }); log.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers)); - await addEventTypesToDb(eventTypesWithUsers); + return await addEventTypesToDb(eventTypesWithUsers); } function addBookingReferencesToDB(bookingReferences: Prisma.BookingReferenceCreateManyInput[]) { @@ -289,10 +341,21 @@ async function addUsersToDb(users: (Prisma.UserCreateInput & { schedules: Prisma await prismock.user.createMany({ data: users, }); + log.silly( "Added users to Db", safeStringify({ - allUsers: await prismock.user.findMany(), + allUsers: await prismock.user.findMany({ + include: { + credentials: true, + schedules: { + include: { + availability: true, + }, + }, + destinationCalendar: true, + }, + }), }) ); } @@ -343,16 +406,28 @@ async function addUsers(users: InputUser[]) { await addUsersToDb(prismaUsersCreate); } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function addAppsToDb(apps: any[]) { + log.silly("TestData: Creating Apps", JSON.stringify({ apps })); + await prismock.app.createMany({ + data: apps, + }); + const allApps = await prismock.app.findMany(); + log.silly("TestData: Apps as in DB", JSON.stringify({ apps: allApps })); +} export async function createBookingScenario(data: ScenarioData) { log.silly("TestData: Creating Scenario", JSON.stringify({ data })); await addUsers(data.users); - - const eventType = await addEventTypes(data.eventTypes, data.users); if (data.apps) { - prismock.app.createMany({ - data: data.apps, - }); + await addAppsToDb( + data.apps.map((app) => { + // Enable the app by default + return { enabled: true, ...app }; + }) + ); } + const eventTypes = await addEventTypes(data.eventTypes, data.users); + data.bookings = data.bookings || []; // allowSuccessfulBookingCreation(); await addBookings(data.bookings); @@ -360,7 +435,7 @@ export async function createBookingScenario(data: ScenarioData) { await addWebhooks(data.webhooks || []); // addPaymentMock(); return { - eventType, + eventTypes, }; } @@ -483,12 +558,11 @@ export const TestData = { }, schedules: { IstWorkHours: { - id: 1, name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT", availability: [ { - userId: null, - eventTypeId: null, + // userId: null, + // eventTypeId: null, days: [0, 1, 2, 3, 4, 5, 6], startTime: new Date("1970-01-01T09:30:00.000Z"), endTime: new Date("1970-01-01T18:00:00.000Z"), @@ -497,21 +571,50 @@ export const TestData = { ], timeZone: Timezones["+5:30"], }, + /** + * Has an overlap with IstEveningShift from 5PM to 6PM IST(11:30AM to 12:30PM GMT) + */ + IstMorningShift: { + name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT", + availability: [ + { + // userId: null, + // eventTypeId: null, + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date("1970-01-01T09:30:00.000Z"), + endTime: new Date("1970-01-01T18:00:00.000Z"), + date: null, + }, + ], + timeZone: Timezones["+5:30"], + }, + /** + * Has an overlap with IstMorningShift from 5PM to 6PM IST(11:30AM to 12:30PM GMT) + */ + IstEveningShift: { + name: "5:00PM to 10PM in India - 11:30AM to 16:30PM in GMT", + availability: [ + { + // userId: null, + // eventTypeId: null, + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date("1970-01-01T17:00:00.000Z"), + endTime: new Date("1970-01-01T22:00:00.000Z"), + date: null, + }, + ], + timeZone: Timezones["+5:30"], + }, IstWorkHoursWithDateOverride: (dateString: string) => ({ - id: 1, name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT but with a Date Override for 2PM to 6PM IST(in GST time it is 8:30AM to 12:30PM)", availability: [ { - userId: null, - eventTypeId: null, days: [0, 1, 2, 3, 4, 5, 6], startTime: new Date("1970-01-01T09:30:00.000Z"), endTime: new Date("1970-01-01T18:00:00.000Z"), date: null, }, { - userId: null, - eventTypeId: null, days: [0, 1, 2, 3, 4, 5, 6], startTime: new Date(`1970-01-01T14:00:00.000Z`), endTime: new Date(`1970-01-01T18:00:00.000Z`), @@ -532,9 +635,7 @@ export const TestData = { }, apps: { "google-calendar": { - slug: "google-calendar", - enabled: true, - dirName: "whatever", + ...appStoreMetadata.googlecalendar, // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore keys: { @@ -545,9 +646,7 @@ export const TestData = { }, }, "daily-video": { - slug: "daily-video", - dirName: "whatever", - enabled: true, + ...appStoreMetadata.dailyvideo, // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore keys: { @@ -560,9 +659,7 @@ export const TestData = { }, }, zoomvideo: { - slug: "zoom", - enabled: true, - dirName: "whatever", + ...appStoreMetadata.zoomvideo, // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore keys: { @@ -575,10 +672,7 @@ export const TestData = { }, }, "stripe-payment": { - //TODO: Read from appStoreMeta - slug: "stripe", - enabled: true, - dirName: "stripepayment", + ...appStoreMetadata.stripepayment, // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore keys: { @@ -608,6 +702,7 @@ export function getOrganizer({ credentials, selectedCalendars, destinationCalendar, + defaultScheduleId, }: { name: string; email: string; @@ -615,6 +710,7 @@ export function getOrganizer({ schedules: InputUser["schedules"]; credentials?: InputCredential[]; selectedCalendars?: InputSelectedCalendar[]; + defaultScheduleId?: number | null; destinationCalendar?: Prisma.DestinationCalendarCreateInput; }) { return { @@ -626,6 +722,7 @@ export function getOrganizer({ credentials, selectedCalendars, destinationCalendar, + defaultScheduleId, }; } @@ -856,7 +953,9 @@ export function mockVideoApp({ url: `http://mock-${metadataLookupKey}.example.com`, }; log.silly("mockSuccessfulVideoMeetingCreation", JSON.stringify({ metadataLookupKey, appStoreLookupKey })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const createMeetingCalls: any[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const updateMeetingCalls: any[] = []; // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore @@ -866,42 +965,50 @@ export function mockVideoApp({ lib: { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore - VideoApiAdapter: () => ({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - createMeeting: (...rest: any[]) => { - if (creationCrash) { - throw new Error("MockVideoApiAdapter.createMeeting fake error"); - } - createMeetingCalls.push(rest); + VideoApiAdapter: (credential) => { + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createMeeting: (...rest: any[]) => { + if (creationCrash) { + throw new Error("MockVideoApiAdapter.createMeeting fake error"); + } + createMeetingCalls.push({ + credential, + args: rest, + }); - return Promise.resolve({ - type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, - ...videoMeetingData, - }); - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - updateMeeting: async (...rest: any[]) => { - if (updationCrash) { - throw new Error("MockVideoApiAdapter.updateMeeting fake error"); - } - const [bookingRef, calEvent] = rest; - updateMeetingCalls.push(rest); - if (!bookingRef.type) { - throw new Error("bookingRef.type is not defined"); - } - if (!calEvent.organizer) { - throw new Error("calEvent.organizer is not defined"); - } - log.silly( - "mockSuccessfulVideoMeetingCreation.updateMeeting", - JSON.stringify({ bookingRef, calEvent }) - ); - return Promise.resolve({ - type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, - ...videoMeetingData, - }); - }, - }), + return Promise.resolve({ + type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, + ...videoMeetingData, + }); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateMeeting: async (...rest: any[]) => { + if (updationCrash) { + throw new Error("MockVideoApiAdapter.updateMeeting fake error"); + } + const [bookingRef, calEvent] = rest; + updateMeetingCalls.push({ + credential, + args: rest, + }); + if (!bookingRef.type) { + throw new Error("bookingRef.type is not defined"); + } + if (!calEvent.organizer) { + throw new Error("calEvent.organizer is not defined"); + } + log.silly( + "mockSuccessfulVideoMeetingCreation.updateMeeting", + JSON.stringify({ bookingRef, calEvent }) + ); + return Promise.resolve({ + type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, + ...videoMeetingData, + }); + }, + }; + }, }, }); }); @@ -1029,3 +1136,25 @@ export async function mockPaymentSuccessWebhookFromStripe({ externalId }: { exte } return { webhookResponse }; } + +export function getExpectedCalEventForBookingRequest({ + bookingRequest, + eventType, +}: { + bookingRequest: ReturnType<typeof getMockRequestDataForBooking>; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + eventType: any; +}) { + return { + // keep adding more fields as needed, so that they can be verified in all scenarios + type: eventType.title, + // Not sure why, but milliseconds are missing in cal Event. + startTime: bookingRequest.start.replace(".000Z", "Z"), + endTime: bookingRequest.end.replace(".000Z", "Z"), + }; +} + +export const enum BookingLocations { + CalVideo = "integrations:daily", + ZoomVideo = "integrations:zoom", +} diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index e988017b9b..3ad22136ca 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -1,6 +1,6 @@ import prismaMock from "../../../../../tests/libs/__mocks__/prisma"; -import type { WebhookTriggerEvents, Booking, BookingReference } from "@prisma/client"; +import type { WebhookTriggerEvents, Booking, BookingReference, DestinationCalendar } from "@prisma/client"; import ical from "node-ical"; import { expect } from "vitest"; import "vitest-fetch-mock"; @@ -182,11 +182,15 @@ export function expectSuccessfulBookingCreationEmails({ emails, organizer, booker, + guests, + otherTeamMembers, iCalUID, }: { emails: Fixtures["emails"]; organizer: { email: string; name: string }; booker: { email: string; name: string }; + guests?: { email: string; name: string }[]; + otherTeamMembers?: { email: string; name: string }[]; iCalUID: string; }) { expect(emails).toHaveEmail( @@ -212,6 +216,39 @@ export function expectSuccessfulBookingCreationEmails({ }, `${booker.name} <${booker.email}>` ); + + if (otherTeamMembers) { + otherTeamMembers.forEach((otherTeamMember) => { + expect(emails).toHaveEmail( + { + htmlToContain: "<title>confirmed_event_type_subject", + // Don't know why but organizer and team members of the eventType don'thave their name here like Booker + to: `${otherTeamMember.email}`, + ics: { + filename: "event.ics", + iCalUID: iCalUID, + }, + }, + `${otherTeamMember.email}` + ); + }); + } + + if (guests) { + guests.forEach((guest) => { + expect(emails).toHaveEmail( + { + htmlToContain: "confirmed_event_type_subject", + to: `${guest.email}`, + ics: { + filename: "event.ics", + iCalUID: iCalUID, + }, + }, + `${guest.name} <${guest.email}` + ); + }); + } } export function expectBrokenIntegrationEmails({ @@ -537,8 +574,9 @@ export function expectSuccessfulCalendarEventCreationInCalendar( updateEventCalls: any[]; }, expected: { - calendarId: string | null; + calendarId?: string | null; videoCallUrl: string; + destinationCalendars: Partial[]; } ) { expect(calendarMock.createEventCalls.length).toBe(1); @@ -553,6 +591,8 @@ export function expectSuccessfulCalendarEventCreationInCalendar( externalId: expected.calendarId, }), ] + : expected.destinationCalendars + ? expect.arrayContaining(expected.destinationCalendars.map((cal) => expect.objectContaining(cal))) : null, videoCallData: expect.objectContaining({ url: expected.videoCallUrl, @@ -584,7 +624,7 @@ export function expectSuccessfulCalendarEventUpdationInCalendar( expect(externalId).toBe(expected.externalCalendarId); } -export function expectSuccessfulVideoMeetingCreationInCalendar( +export function expectSuccessfulVideoMeetingCreation( videoMock: { // eslint-disable-next-line @typescript-eslint/no-explicit-any createMeetingCalls: any[]; @@ -592,19 +632,20 @@ export function expectSuccessfulVideoMeetingCreationInCalendar( updateMeetingCalls: any[]; }, expected: { - externalCalendarId: string; - calEvent: Partial; - uid: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + credential: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + calEvent: any; } ) { expect(videoMock.createMeetingCalls.length).toBe(1); const call = videoMock.createMeetingCalls[0]; - const uid = call[0]; - const calendarEvent = call[1]; - const externalId = call[2]; - expect(uid).toBe(expected.uid); - expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent)); - expect(externalId).toBe(expected.externalCalendarId); + const callArgs = call.args; + const calEvent = callArgs[0]; + const credential = call.credential; + + expect(credential).toEqual(expected.credential); + expect(calEvent).toEqual(expected.calEvent); } export function expectSuccessfulVideoMeetingUpdationInCalendar( @@ -622,8 +663,8 @@ export function expectSuccessfulVideoMeetingUpdationInCalendar( ) { expect(videoMock.updateMeetingCalls.length).toBe(1); const call = videoMock.updateMeetingCalls[0]; - const bookingRef = call[0]; - const calendarEvent = call[1]; + const bookingRef = call.args[0]; + const calendarEvent = call.args[1]; expect(bookingRef).toEqual(expect.objectContaining(expected.bookingRef)); expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent)); } diff --git a/packages/app-store/appStoreMetaData.ts b/packages/app-store/appStoreMetaData.ts index 74f6fdb95d..72502226eb 100644 --- a/packages/app-store/appStoreMetaData.ts +++ b/packages/app-store/appStoreMetaData.ts @@ -5,7 +5,7 @@ import { getNormalizedAppMetadata } from "./getNormalizedAppMetadata"; type RawAppStoreMetaData = typeof rawAppStoreMetadata; type AppStoreMetaData = { - [key in keyof RawAppStoreMetaData]: AppMeta; + [key in keyof RawAppStoreMetaData]: Omit & { dirName: string }; }; export const appStoreMetadata = {} as AppStoreMetaData; diff --git a/packages/app-store/getNormalizedAppMetadata.ts b/packages/app-store/getNormalizedAppMetadata.ts index b3dec5fe78..de9c6ce6a7 100644 --- a/packages/app-store/getNormalizedAppMetadata.ts +++ b/packages/app-store/getNormalizedAppMetadata.ts @@ -19,7 +19,7 @@ export const getNormalizedAppMetadata = (appMeta: RawAppStoreMetaData[keyof RawA dirName, __template: "", ...appMeta, - } as AppStoreMetaData[keyof AppStoreMetaData]; + } as Omit & { dirName: string }; metadata.logo = getAppAssetFullPath(metadata.logo, { dirName, isTemplate: metadata.isTemplate, diff --git a/packages/app-store/utils.ts b/packages/app-store/utils.ts index 4ceeb6aae3..aaacb56292 100644 --- a/packages/app-store/utils.ts +++ b/packages/app-store/utils.ts @@ -4,6 +4,9 @@ import type { AppCategories } from "@prisma/client"; // import appStore from "./index"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import type { EventLocationType } from "@calcom/app-store/locations"; +import logger from "@calcom/lib/logger"; +import { getPiiFreeCredential } from "@calcom/lib/piiFreeData"; +import { safeStringify } from "@calcom/lib/safeStringify"; import type { App, AppMeta } from "@calcom/types/App"; import type { CredentialPayload } from "@calcom/types/Credential"; @@ -52,7 +55,7 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials? /** If the app is a globally installed one, let's inject it's key */ if (appMeta.isGlobal) { - appCredentials.push({ + const credential = { id: 0, type: appMeta.type, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -65,7 +68,12 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials? team: { name: "Global", }, - }); + }; + logger.debug( + `${appMeta.type} is a global app, injecting credential`, + safeStringify(getPiiFreeCredential(credential)) + ); + appCredentials.push(credential); } /** Check if app has location option AND add it if user has credentials for it */ diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index dba0812c07..35be0141df 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -460,16 +460,23 @@ export default class EventManager { /** @fixme potential bug since Google Meet are saved as `integrations:google:meet` and there are no `google:meet` type in our DB */ const integrationName = event.location.replace("integrations:", ""); - - let videoCredential = event.conferenceCredentialId - ? this.videoCredentials.find((credential) => credential.id === event.conferenceCredentialId) - : this.videoCredentials - // Whenever a new video connection is added, latest credentials are added with the highest ID. - // Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order - .sort((a, b) => { - return b.id - a.id; - }) - .find((credential: CredentialPayload) => credential.type.includes(integrationName)); + let videoCredential; + if (event.conferenceCredentialId) { + videoCredential = this.videoCredentials.find( + (credential) => credential.id === event.conferenceCredentialId + ); + } else { + videoCredential = this.videoCredentials + // Whenever a new video connection is added, latest credentials are added with the highest ID. + // Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order + .sort((a, b) => { + return b.id - a.id; + }) + .find((credential: CredentialPayload) => credential.type.includes(integrationName)); + log.warn( + `Could not find conferenceCredentialId for event with location: ${event.location}, trying to use last added video credential` + ); + } /** * This might happen if someone tries to use a location with a missing credential, so we fallback to Cal Video. diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 98178d4b55..d2078b0fd7 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -9,6 +9,7 @@ import { buildDateRanges, subtract } from "@calcom/lib/date-ranges"; import { HttpError } from "@calcom/lib/http-error"; import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit"; import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import { checkBookingLimit } from "@calcom/lib/server"; import { performance } from "@calcom/lib/server/perfObserver"; import { getTotalBookingDuration } from "@calcom/lib/server/queries"; @@ -25,6 +26,7 @@ import type { import { getBusyTimes, getBusyTimesForLimitChecks } from "./getBusyTimes"; +const log = logger.getChildLogger({ prefix: ["getUserAvailability"] }); const availabilitySchema = z .object({ dateFrom: stringToDayjs, @@ -161,7 +163,12 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni if (userId) where.id = userId; const user = initialData?.user || (await getUser(where)); + if (!user) throw new HttpError({ statusCode: 404, message: "No user found" }); + log.debug( + "getUserAvailability for user", + safeStringify({ user: { id: user.id }, slot: { dateFrom, dateTo } }) + ); let eventType: EventType | null = initialData?.eventType || null; if (!eventType && eventTypeId) eventType = await getEventType(eventTypeId); @@ -225,10 +232,17 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni (schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId )[0]; - const schedule = - !eventType?.metadata?.config?.useHostSchedulesForTeamEvent && eventType?.schedule - ? eventType.schedule - : userSchedule; + const useHostSchedulesForTeamEvent = eventType?.metadata?.config?.useHostSchedulesForTeamEvent; + const schedule = !useHostSchedulesForTeamEvent && eventType?.schedule ? eventType.schedule : userSchedule; + log.debug( + "Using schedule:", + safeStringify({ + chosenSchedule: schedule, + eventTypeSchedule: eventType?.schedule, + userSchedule: userSchedule, + useHostSchedulesForTeamEvent: eventType?.metadata?.config?.useHostSchedulesForTeamEvent, + }) + ); const startGetWorkingHours = performance.now(); @@ -270,7 +284,7 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes); - logger.debug( + log.debug( `getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`, JSON.stringify({ workingHoursInUtc: workingHours, diff --git a/packages/core/videoClient.ts b/packages/core/videoClient.ts index 6d7be5535e..9d6281f1b1 100644 --- a/packages/core/videoClient.ts +++ b/packages/core/videoClient.ts @@ -55,7 +55,7 @@ const getBusyVideoTimes = async (withCredentials: CredentialPayload[]) => const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEvent) => { const uid: string = getUid(calEvent); - log.silly( + log.debug( "createMeeting", safeStringify({ credential: getPiiFreeCredential(credential), @@ -100,11 +100,13 @@ const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEv }, }); - if (!enabledApp?.enabled) throw "Current location app is not enabled"; + if (!enabledApp?.enabled) + throw `Location app ${credential.appId} is either disabled or not seeded at all`; createdMeeting = await firstVideoAdapter?.createMeeting(calEvent); returnObject = { ...returnObject, createdEvent: createdMeeting, success: true }; + log.debug("created Meeting", safeStringify(returnObject)); } catch (err) { await sendBrokenIntegrationEmail(calEvent, "video"); log.error("createMeeting failed", safeStringify({ err, calEvent: getPiiFreeCalendarEvent(calEvent) })); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 5475445fa2..ee229bb434 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -379,7 +379,6 @@ async function ensureAvailableUsers( ) : undefined; - log.debug("getUserAvailability for users", JSON.stringify({ users: eventType.users.map((u) => u.id) })); /** Let's start checking for availability */ for (const user of eventType.users) { const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability( @@ -968,7 +967,7 @@ async function handler( if ( availableUsers.filter((user) => user.isFixed).length !== users.filter((user) => user.isFixed).length ) { - throw new Error("Some users are unavailable for booking."); + throw new Error("Some of the hosts are unavailable for booking."); } // Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer. users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers]; diff --git a/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts new file mode 100644 index 0000000000..fcfcef7975 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts @@ -0,0 +1,7 @@ +import { describe } from "vitest"; + +import { test } from "@calcom/web/test/fixtures/fixtures"; + +describe("Booking Limits", () => { + test.todo("Test these cases that were failing earlier https://github.com/calcom/cal.com/pull/10480"); +}); diff --git a/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts new file mode 100644 index 0000000000..a22dd59679 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts @@ -0,0 +1,10 @@ +import { describe } from "vitest"; + +import { test } from "@calcom/web/test/fixtures/fixtures"; + +import { setupAndTeardown } from "./lib/setupAndTeardown"; + +describe("handleNewBooking", () => { + setupAndTeardown(); + test.todo("Dynamic Group Booking"); +}); diff --git a/packages/features/bookings/lib/handleNewBooking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts similarity index 71% rename from packages/features/bookings/lib/handleNewBooking.test.ts rename to packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts index 9299fb01e6..8f3a35f22b 100644 --- a/packages/features/bookings/lib/handleNewBooking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts @@ -7,15 +7,12 @@ * * They don't intend to test what the apps logic should do, but rather test if the apps are called with the correct data. For testing that, once should write tests within each app. */ -import prismaMock from "../../../../tests/libs/__mocks__/prisma"; - import type { Request, Response } from "express"; import type { NextApiRequest, NextApiResponse } from "next"; -import { createMocks } from "node-mocks-http"; -import { describe, expect, beforeEach } from "vitest"; +import { describe, expect } from "vitest"; +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { WEBAPP_URL } from "@calcom/lib/constants"; -import logger from "@calcom/lib/logger"; import { BookingStatus } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; import { @@ -27,8 +24,6 @@ import { getBooker, getScenarioData, getZoomAppCredential, - enableEmailFeature, - mockNoTranslations, mockErrorOnVideoMeetingCreation, mockSuccessfulVideoMeetingCreation, mockCalendarToHaveNoBusySlots, @@ -39,7 +34,7 @@ import { mockCalendar, mockCalendarToCrashOnCreateEvent, mockVideoAppToCrashOnCreateMeeting, - mockCalendarToCrashOnUpdateEvent, + BookingLocations, } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { expectWorkflowToBeTriggered, @@ -50,33 +45,23 @@ import { expectBookingRequestedWebhookToHaveBeenFired, expectBookingCreatedWebhookToHaveBeenFired, expectBookingPaymentIntiatedWebhookToHaveBeenFired, - expectBookingRescheduledWebhookToHaveBeenFired, - expectSuccessfulBookingRescheduledEmails, - expectSuccessfulCalendarEventUpdationInCalendar, - expectSuccessfulVideoMeetingUpdationInCalendar, expectBrokenIntegrationEmails, expectSuccessfulCalendarEventCreationInCalendar, - expectBookingInDBToBeRescheduledFromTo, } from "@calcom/web/test/utils/bookingScenario/expects"; -type CustomNextApiRequest = NextApiRequest & Request; +import { createMockNextJsRequest } from "./lib/createMockNextJsRequest"; +import { getMockRequestDataForBooking } from "./lib/getMockRequestDataForBooking"; +import { setupAndTeardown } from "./lib/setupAndTeardown"; -type CustomNextApiResponse = NextApiResponse & Response; +export type CustomNextApiRequest = NextApiRequest & Request; + +export type CustomNextApiResponse = NextApiResponse & Response; // Local test runs sometime gets too slow const timeout = process.env.CI ? 5000 : 20000; describe("handleNewBooking", () => { - beforeEach(() => { - // Required to able to generate token in email in some cases - process.env.CALENDSO_ENCRYPTION_KEY = "abcdefghjnmkljhjklmnhjklkmnbhjui"; - process.env.STRIPE_WEBHOOK_SECRET = "MOCK_STRIPE_WEBHOOK_SECRET"; - mockNoTranslations(); - // mockEnableEmailFeature(); - enableEmailFeature(); - globalThis.testEmails = []; - fetchMock.resetMocks(); - }); + setupAndTeardown(); - describe("Fresh Booking:", () => { + describe("Fresh/New Booking:", () => { test( `should create a successful booking with Cal Video(Daily Video) if no explicit location is provided 1. Should create a booking in the database @@ -158,7 +143,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }); @@ -175,7 +160,7 @@ describe("handleNewBooking", () => { }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: BookingLocations.CalVideo, }); await expectBookingToBeInDatabase({ @@ -186,14 +171,14 @@ describe("handleNewBooking", () => { status: BookingStatus.ACCEPTED, references: [ { - type: "daily_video", + type: appStoreMetadata.dailyvideo.type, uid: "MOCK_ID", meetingId: "MOCK_ID", meetingPassword: "MOCK_PASS", meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", }, { - type: "google_calendar", + type: appStoreMetadata.googlecalendar.type, uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", meetingPassword: "MOCK_PASSWORD", @@ -218,7 +203,7 @@ describe("handleNewBooking", () => { expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl: "http://my-webhook.example.com", videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, }); @@ -303,7 +288,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }); @@ -320,7 +305,7 @@ describe("handleNewBooking", () => { }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: BookingLocations.CalVideo, }); await expectBookingToBeInDatabase({ @@ -331,14 +316,14 @@ describe("handleNewBooking", () => { status: BookingStatus.ACCEPTED, references: [ { - type: "daily_video", + type: appStoreMetadata.dailyvideo.type, uid: "MOCK_ID", meetingId: "MOCK_ID", meetingPassword: "MOCK_PASS", meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", }, { - type: "google_calendar", + type: appStoreMetadata.googlecalendar.type, uid: "GOOGLE_CALENDAR_EVENT_ID", meetingId: "GOOGLE_CALENDAR_EVENT_ID", meetingPassword: "MOCK_PASSWORD", @@ -365,7 +350,7 @@ describe("handleNewBooking", () => { expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl: "http://my-webhook.example.com", videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, }); @@ -451,7 +436,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }); @@ -468,7 +453,7 @@ describe("handleNewBooking", () => { }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: BookingLocations.CalVideo, }); await expectBookingToBeInDatabase({ @@ -479,14 +464,14 @@ describe("handleNewBooking", () => { status: BookingStatus.ACCEPTED, references: [ { - type: "daily_video", + type: appStoreMetadata.dailyvideo.type, uid: "MOCK_ID", meetingId: "MOCK_ID", meetingPassword: "MOCK_PASS", meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", }, { - type: "google_calendar", + type: appStoreMetadata.googlecalendar.type, uid: "GOOGLE_CALENDAR_EVENT_ID", meetingId: "GOOGLE_CALENDAR_EVENT_ID", meetingPassword: "MOCK_PASSWORD", @@ -511,7 +496,7 @@ describe("handleNewBooking", () => { expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl: "http://my-webhook.example.com", videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, }); @@ -605,7 +590,7 @@ describe("handleNewBooking", () => { status: BookingStatus.ACCEPTED, references: [ { - type: "google_calendar", + type: appStoreMetadata.googlecalendar.type, // A reference is still created in case of event creation failure, with nullish values. Not sure what's the purpose for this. uid: "", meetingId: null, @@ -629,6 +614,156 @@ describe("handleNewBooking", () => { }, timeout ); + + test( + "If destination calendar has no credential ID due to some reason, it should create the event in first connected calendar instead", + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + }, + }); + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + // await prismaMock.destinationCalendar.update({ + // where: { + // userId: organizer.id, + // }, + // data: { + // credentialId: null, + // }, + // }); + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "MOCK_ID", + id: "GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + expect(createdBooking.responses).toContain({ + email: booker.email, + name: booker.name, + }); + + expect(createdBooking).toContain({ + location: BookingLocations.CalVideo, + }); + + await expectBookingToBeInDatabase({ + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "GOOGLE_CALENDAR_EVENT_ID", + meetingId: "GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + + expectWorkflowToBeTriggered(); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + calendarId: "organizer@google-calendar.com", + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); + + expectSuccessfulBookingCreationEmails({ + booker, + organizer, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + timeout + ); }); describe("Video Meeting Creation", () => { @@ -690,7 +825,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:zoom" }, + location: { optionValue: "", value: BookingLocations.ZoomVideo }, }, }, }), @@ -708,7 +843,7 @@ describe("handleNewBooking", () => { expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:zoom", + location: BookingLocations.ZoomVideo, subscriberUrl, videoCallUrl: "http://mock-zoomvideo.example.com", }); @@ -775,7 +910,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:zoom" }, + location: { optionValue: "", value: BookingLocations.ZoomVideo }, }, }, }), @@ -787,7 +922,7 @@ describe("handleNewBooking", () => { expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:zoom", + location: BookingLocations.ZoomVideo, subscriberUrl, videoCallUrl: null, }); @@ -1031,7 +1166,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }); @@ -1048,7 +1183,7 @@ describe("handleNewBooking", () => { }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: BookingLocations.CalVideo, }); await expectBookingToBeInDatabase({ @@ -1070,7 +1205,7 @@ describe("handleNewBooking", () => { expectBookingRequestedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl, eventType: scenarioData.eventTypes[0], }); @@ -1153,7 +1288,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }); @@ -1170,7 +1305,7 @@ describe("handleNewBooking", () => { }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: BookingLocations.CalVideo, }); await expectBookingToBeInDatabase({ @@ -1193,7 +1328,7 @@ describe("handleNewBooking", () => { expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl, videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, }); @@ -1275,7 +1410,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }); @@ -1292,7 +1427,7 @@ describe("handleNewBooking", () => { }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: BookingLocations.CalVideo, }); await expectBookingToBeInDatabase({ @@ -1310,7 +1445,7 @@ describe("handleNewBooking", () => { expectBookingRequestedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl, eventType: scenarioData.eventTypes[0], }); @@ -1369,7 +1504,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }), @@ -1574,7 +1709,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }); @@ -1590,7 +1725,7 @@ describe("handleNewBooking", () => { name: booker.name, }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: BookingLocations.CalVideo, paymentUid: paymentUid, }); await expectBookingToBeInDatabase({ @@ -1606,7 +1741,7 @@ describe("handleNewBooking", () => { expectBookingPaymentIntiatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl: "http://my-webhook.example.com", // eslint-disable-next-line @typescript-eslint/no-non-null-assertion paymentId: createdBooking.paymentId!, @@ -1626,7 +1761,7 @@ describe("handleNewBooking", () => { expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl: "http://my-webhook.example.com", videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, paidEvent: true, @@ -1716,7 +1851,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }); @@ -1731,7 +1866,7 @@ describe("handleNewBooking", () => { name: booker.name, }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: BookingLocations.CalVideo, paymentUid: paymentUid, }); await expectBookingToBeInDatabase({ @@ -1746,7 +1881,7 @@ describe("handleNewBooking", () => { expectBookingPaymentIntiatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl: "http://my-webhook.example.com", // eslint-disable-next-line @typescript-eslint/no-non-null-assertion paymentId: createdBooking.paymentId!, @@ -1765,7 +1900,7 @@ describe("handleNewBooking", () => { expectBookingRequestedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl, paidEvent: true, eventType: scenarioData.eventTypes[0], @@ -1776,627 +1911,5 @@ describe("handleNewBooking", () => { }); }); - describe("Team Events", () => { - test.todo("Collective event booking"); - test.todo("Round Robin booking"); - }); - - describe("Team Plus Paid Events", () => { - test.todo("Collective event booking"); - test.todo("Round Robin booking"); - }); - - test.todo("Calendar and video Apps installed on a Team Account"); - - test.todo("Managed Event Type booking"); - - test.todo("Dynamic Group Booking"); - - describe("Booking Limits", () => { - test.todo("Test these cases that were failing earlier https://github.com/calcom/cal.com/pull/10480"); - }); - - describe("Reschedule", () => { - test( - `should rechedule an existing booking successfully with Cal Video(Daily Video) - 1. Should cancel the existing booking - 2. Should create a new booking in the database - 3. Should send emails to the booker as well as organizer - 4. Should trigger BOOKING_RESCHEDULED webhook - `, - async ({ emails }) => { - const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; - const booker = getBooker({ - email: "booker@example.com", - name: "Booker", - }); - - const organizer = getOrganizer({ - name: "Organizer", - email: "organizer@example.com", - id: 101, - schedules: [TestData.schedules.IstWorkHours], - credentials: [getGoogleCalendarCredential()], - selectedCalendars: [TestData.selectedCalendars.google], - }); - - const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); - const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; - await createBookingScenario( - getScenarioData({ - webhooks: [ - { - userId: organizer.id, - eventTriggers: ["BOOKING_CREATED"], - subscriberUrl: "http://my-webhook.example.com", - active: true, - eventTypeId: 1, - appId: null, - }, - ], - eventTypes: [ - { - id: 1, - slotInterval: 45, - length: 45, - users: [ - { - id: 101, - }, - ], - }, - ], - bookings: [ - { - uid: uidOfBookingToBeRescheduled, - eventTypeId: 1, - status: BookingStatus.ACCEPTED, - startTime: `${plus1DateString}T05:00:00.000Z`, - endTime: `${plus1DateString}T05:15:00.000Z`, - references: [ - { - type: "daily_video", - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - }, - { - type: "google_calendar", - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASSWORD", - meetingUrl: "https://UNUSED_URL", - externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", - credentialId: undefined, - }, - ], - }, - ], - organizer, - apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], - }) - ); - - const videoMock = mockSuccessfulVideoMeetingCreation({ - metadataLookupKey: "dailyvideo", - }); - - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { - create: { - uid: "MOCK_ID", - }, - update: { - uid: "UPDATED_MOCK_ID", - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - }, - }); - - const mockBookingData = getMockRequestDataForBooking({ - data: { - eventTypeId: 1, - rescheduleUid: uidOfBookingToBeRescheduled, - start: `${plus1DateString}T04:00:00.000Z`, - end: `${plus1DateString}T04:15:00.000Z`, - responses: { - email: booker.email, - name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, - }, - }, - }); - - const { req } = createMockNextJsRequest({ - method: "POST", - body: mockBookingData, - }); - - const createdBooking = await handleNewBooking(req); - - const previousBooking = await prismaMock.booking.findUnique({ - where: { - uid: uidOfBookingToBeRescheduled, - }, - }); - - logger.silly({ - previousBooking, - allBookings: await prismaMock.booking.findMany(), - }); - - // Expect previous booking to be cancelled - await expectBookingToBeInDatabase({ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: uidOfBookingToBeRescheduled, - status: BookingStatus.CANCELLED, - }); - - expect(previousBooking?.status).toBe(BookingStatus.CANCELLED); - /** - * Booking Time should be new time - */ - expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`); - expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`); - - await expectBookingInDBToBeRescheduledFromTo({ - from: { - uid: uidOfBookingToBeRescheduled, - }, - to: { - description: "", - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: createdBooking.uid!, - eventTypeId: mockBookingData.eventTypeId, - status: BookingStatus.ACCEPTED, - location: "integrations:daily", - responses: expect.objectContaining({ - email: booker.email, - name: booker.name, - }), - references: [ - { - type: "daily_video", - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - }, - { - type: "google_calendar", - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASSWORD", - meetingUrl: "https://UNUSED_URL", - externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", - }, - ], - }, - }); - - expectWorkflowToBeTriggered(); - - expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, { - calEvent: { - location: "http://mock-dailyvideo.example.com", - }, - bookingRef: { - type: "daily_video", - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - }, - }); - - expectSuccessfulCalendarEventUpdationInCalendar(calendarMock, { - externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", - calEvent: { - videoCallData: expect.objectContaining({ - url: "http://mock-dailyvideo.example.com", - }), - }, - uid: "MOCK_ID", - }); - - expectSuccessfulBookingRescheduledEmails({ - booker, - organizer, - emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - }); - expectBookingRescheduledWebhookToHaveBeenFired({ - booker, - organizer, - location: "integrations:daily", - subscriberUrl: "http://my-webhook.example.com", - videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, - }); - }, - timeout - ); - test( - `should rechedule a booking successfully and update the event in the same externalCalendarId as was used in the booking earlier. - 1. Should cancel the existing booking - 2. Should create a new booking in the database - 3. Should send emails to the booker as well as organizer - 4. Should trigger BOOKING_RESCHEDULED webhook - `, - async ({ emails }) => { - const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; - const booker = getBooker({ - email: "booker@example.com", - name: "Booker", - }); - - const organizer = getOrganizer({ - name: "Organizer", - email: "organizer@example.com", - id: 101, - schedules: [TestData.schedules.IstWorkHours], - credentials: [getGoogleCalendarCredential()], - selectedCalendars: [TestData.selectedCalendars.google], - }); - - const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); - const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; - await createBookingScenario( - getScenarioData({ - webhooks: [ - { - userId: organizer.id, - eventTriggers: ["BOOKING_CREATED"], - subscriberUrl: "http://my-webhook.example.com", - active: true, - eventTypeId: 1, - appId: null, - }, - ], - eventTypes: [ - { - id: 1, - slotInterval: 45, - length: 45, - users: [ - { - id: 101, - }, - ], - destinationCalendar: { - integration: "google_calendar", - externalId: "event-type-1@example.com", - }, - }, - ], - bookings: [ - { - uid: uidOfBookingToBeRescheduled, - eventTypeId: 1, - status: BookingStatus.ACCEPTED, - startTime: `${plus1DateString}T05:00:00.000Z`, - endTime: `${plus1DateString}T05:15:00.000Z`, - references: [ - { - type: "daily_video", - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - }, - { - type: "google_calendar", - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASSWORD", - meetingUrl: "https://UNUSED_URL", - externalCalendarId: "existing-event-type@example.com", - credentialId: undefined, - }, - ], - }, - ], - organizer, - apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], - }) - ); - - const videoMock = mockSuccessfulVideoMeetingCreation({ - metadataLookupKey: "dailyvideo", - }); - - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { - create: { - uid: "MOCK_ID", - }, - update: { - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - uid: "UPDATED_MOCK_ID", - }, - }); - - const mockBookingData = getMockRequestDataForBooking({ - data: { - eventTypeId: 1, - rescheduleUid: uidOfBookingToBeRescheduled, - start: `${plus1DateString}T04:00:00.000Z`, - end: `${plus1DateString}T04:15:00.000Z`, - responses: { - email: booker.email, - name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, - }, - }, - }); - - const { req } = createMockNextJsRequest({ - method: "POST", - body: mockBookingData, - }); - - const createdBooking = await handleNewBooking(req); - - /** - * Booking Time should be new time - */ - expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`); - expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`); - - await expectBookingInDBToBeRescheduledFromTo({ - from: { - uid: uidOfBookingToBeRescheduled, - }, - to: { - description: "", - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: createdBooking.uid!, - eventTypeId: mockBookingData.eventTypeId, - status: BookingStatus.ACCEPTED, - location: "integrations:daily", - responses: expect.objectContaining({ - email: booker.email, - name: booker.name, - }), - references: [ - { - type: "daily_video", - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - }, - { - type: "google_calendar", - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASSWORD", - meetingUrl: "https://UNUSED_URL", - externalCalendarId: "existing-event-type@example.com", - }, - ], - }, - }); - - expectWorkflowToBeTriggered(); - - expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, { - calEvent: { - location: "http://mock-dailyvideo.example.com", - }, - bookingRef: { - type: "daily_video", - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - }, - }); - - // updateEvent uses existing booking's externalCalendarId to update the event in calendar. - // and not the event-type's organizer's which is event-type-1@example.com - expectSuccessfulCalendarEventUpdationInCalendar(calendarMock, { - externalCalendarId: "existing-event-type@example.com", - calEvent: { - location: "http://mock-dailyvideo.example.com", - }, - uid: "MOCK_ID", - }); - - expectSuccessfulBookingRescheduledEmails({ - booker, - organizer, - emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - }); - expectBookingRescheduledWebhookToHaveBeenFired({ - booker, - organizer, - location: "integrations:daily", - subscriberUrl: "http://my-webhook.example.com", - videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, - }); - }, - timeout - ); - - test( - `an error in updating a calendar event should not stop the rescheduling - Current behaviour is wrong as the booking is resheduled but no-one is notified of it`, - async ({}) => { - const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; - const booker = getBooker({ - email: "booker@example.com", - name: "Booker", - }); - - const organizer = getOrganizer({ - name: "Organizer", - email: "organizer@example.com", - id: 101, - schedules: [TestData.schedules.IstWorkHours], - credentials: [getGoogleCalendarCredential()], - selectedCalendars: [TestData.selectedCalendars.google], - destinationCalendar: { - integration: "google_calendar", - externalId: "organizer@google-calendar.com", - }, - }); - const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; - const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); - - await createBookingScenario( - getScenarioData({ - webhooks: [ - { - userId: organizer.id, - eventTriggers: ["BOOKING_CREATED"], - subscriberUrl: "http://my-webhook.example.com", - active: true, - eventTypeId: 1, - appId: null, - }, - ], - eventTypes: [ - { - id: 1, - slotInterval: 45, - length: 45, - users: [ - { - id: 101, - }, - ], - }, - ], - bookings: [ - { - uid: uidOfBookingToBeRescheduled, - eventTypeId: 1, - status: BookingStatus.ACCEPTED, - startTime: `${plus1DateString}T05:00:00.000Z`, - endTime: `${plus1DateString}T05:15:00.000Z`, - references: [ - { - type: "daily_video", - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - }, - { - type: "google_calendar", - uid: "ORIGINAL_BOOKING_UID", - meetingId: "ORIGINAL_MEETING_ID", - meetingPassword: "ORIGINAL_MEETING_PASSWORD", - meetingUrl: "https://ORIGINAL_MEETING_URL", - externalCalendarId: "existing-event-type@example.com", - credentialId: undefined, - }, - ], - }, - ], - organizer, - apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], - }) - ); - - const _calendarMock = mockCalendarToCrashOnUpdateEvent("googlecalendar"); - - const mockBookingData = getMockRequestDataForBooking({ - data: { - eventTypeId: 1, - rescheduleUid: uidOfBookingToBeRescheduled, - responses: { - email: booker.email, - name: booker.name, - location: { optionValue: "", value: "New York" }, - }, - }, - }); - - const { req } = createMockNextJsRequest({ - method: "POST", - body: mockBookingData, - }); - - const createdBooking = await handleNewBooking(req); - - await expectBookingInDBToBeRescheduledFromTo({ - from: { - uid: uidOfBookingToBeRescheduled, - }, - to: { - description: "", - location: "New York", - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: createdBooking.uid!, - eventTypeId: mockBookingData.eventTypeId, - status: BookingStatus.ACCEPTED, - responses: expect.objectContaining({ - email: booker.email, - name: booker.name, - }), - references: [ - { - type: "google_calendar", - // A reference is still created in case of event creation failure, with nullish values. Not sure what's the purpose for this. - uid: "ORIGINAL_BOOKING_UID", - meetingId: "ORIGINAL_MEETING_ID", - meetingPassword: "ORIGINAL_MEETING_PASSWORD", - meetingUrl: "https://ORIGINAL_MEETING_URL", - }, - ], - }, - }); - - expectWorkflowToBeTriggered(); - - // FIXME: We should send Broken Integration emails on calendar event updation failure - // expectBrokenIntegrationEmails({ booker, organizer, emails }); - - expectBookingRescheduledWebhookToHaveBeenFired({ - booker, - organizer, - location: "New York", - subscriberUrl: "http://my-webhook.example.com", - }); - }, - timeout - ); - }); + test.todo("CRM calendar events creation verification"); }); - -function createMockNextJsRequest(...args: Parameters) { - return createMocks(...args); -} - -function getBasicMockRequestDataForBooking() { - return { - start: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`, - end: `${getDate({ dateIncrement: 1 }).dateString}T04:30:00.000Z`, - eventTypeSlug: "no-confirmation", - timeZone: "Asia/Calcutta", - language: "en", - user: "teampro", - metadata: {}, - hasHashedBookingLink: false, - hashedLink: null, - }; -} - -function getMockRequestDataForBooking({ - data, -}: { - data: Partial> & { - eventTypeId: number; - rescheduleUid?: string; - bookingUid?: string; - responses: { - email: string; - name: string; - location: { optionValue: ""; value: string }; - }; - }; -}) { - return { - ...getBasicMockRequestDataForBooking(), - ...data, - }; -} diff --git a/packages/features/bookings/lib/handleNewBooking/test/lib/createMockNextJsRequest.ts b/packages/features/bookings/lib/handleNewBooking/test/lib/createMockNextJsRequest.ts new file mode 100644 index 0000000000..d9d321544f --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/lib/createMockNextJsRequest.ts @@ -0,0 +1,7 @@ +import { createMocks } from "node-mocks-http"; + +import type { CustomNextApiRequest, CustomNextApiResponse } from "../fresh-booking.test"; + +export function createMockNextJsRequest(...args: Parameters) { + return createMocks(...args); +} diff --git a/packages/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking.ts b/packages/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking.ts new file mode 100644 index 0000000000..57ea353ee8 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking.ts @@ -0,0 +1,34 @@ +import { getDate } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; + +export function getBasicMockRequestDataForBooking() { + return { + start: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T04:30:00.000Z`, + eventTypeSlug: "no-confirmation", + timeZone: "Asia/Calcutta", + language: "en", + user: "teampro", + metadata: {}, + hasHashedBookingLink: false, + hashedLink: null, + }; +} +export function getMockRequestDataForBooking({ + data, +}: { + data: Partial> & { + eventTypeId: number; + rescheduleUid?: string; + bookingUid?: string; + responses: { + email: string; + name: string; + location: { optionValue: ""; value: string }; + }; + }; +}) { + return { + ...getBasicMockRequestDataForBooking(), + ...data, + }; +} diff --git a/packages/features/bookings/lib/handleNewBooking/test/lib/setupAndTeardown.ts b/packages/features/bookings/lib/handleNewBooking/test/lib/setupAndTeardown.ts new file mode 100644 index 0000000000..d910f33918 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/lib/setupAndTeardown.ts @@ -0,0 +1,29 @@ +import { beforeEach, afterEach } from "vitest"; + +import { + enableEmailFeature, + mockNoTranslations, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; + +export function setupAndTeardown() { + beforeEach(() => { + // Required to able to generate token in email in some cases + process.env.CALENDSO_ENCRYPTION_KEY = "abcdefghjnmkljhjklmnhjklkmnbhjui"; + process.env.STRIPE_WEBHOOK_SECRET = "MOCK_STRIPE_WEBHOOK_SECRET"; + // We are setting it in vitest.config.ts because otherwise it's too late to set it. + // process.env.DAILY_API_KEY = "MOCK_DAILY_API_KEY"; + mockNoTranslations(); + // mockEnableEmailFeature(); + enableEmailFeature(); + globalThis.testEmails = []; + fetchMock.resetMocks(); + }); + afterEach(() => { + delete process.env.CALENDSO_ENCRYPTION_KEY; + delete process.env.STRIPE_WEBHOOK_SECRET; + delete process.env.DAILY_API_KEY; + globalThis.testEmails = []; + fetchMock.resetMocks(); + // process.env.DAILY_API_KEY = "MOCK_DAILY_API_KEY"; + }); +} diff --git a/packages/features/bookings/lib/handleNewBooking/test/managed-event-type-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/managed-event-type-booking.test.ts new file mode 100644 index 0000000000..81a10098aa --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/managed-event-type-booking.test.ts @@ -0,0 +1,11 @@ +import { describe } from "vitest"; + +import { test } from "@calcom/web/test/fixtures/fixtures"; + +import { setupAndTeardown } from "./lib/setupAndTeardown"; + +describe("handleNewBooking", () => { + setupAndTeardown(); + + test.todo("Managed Event Type booking"); +}); diff --git a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts new file mode 100644 index 0000000000..9a739b0385 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts @@ -0,0 +1,608 @@ +import prismaMock from "../../../../../../tests/libs/__mocks__/prisma"; + +import { describe, expect } from "vitest"; + +import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { test } from "@calcom/web/test/fixtures/fixtures"; +import { + createBookingScenario, + getDate, + getGoogleCalendarCredential, + TestData, + getOrganizer, + getBooker, + getScenarioData, + mockSuccessfulVideoMeetingCreation, + mockCalendarToHaveNoBusySlots, + mockCalendarToCrashOnUpdateEvent, + BookingLocations, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { + expectWorkflowToBeTriggered, + expectBookingToBeInDatabase, + expectBookingRescheduledWebhookToHaveBeenFired, + expectSuccessfulBookingRescheduledEmails, + expectSuccessfulCalendarEventUpdationInCalendar, + expectSuccessfulVideoMeetingUpdationInCalendar, + expectBookingInDBToBeRescheduledFromTo, +} from "@calcom/web/test/utils/bookingScenario/expects"; + +import { createMockNextJsRequest } from "./lib/createMockNextJsRequest"; +import { getMockRequestDataForBooking } from "./lib/getMockRequestDataForBooking"; +import { setupAndTeardown } from "./lib/setupAndTeardown"; + +// Local test runs sometime gets too slow +const timeout = process.env.CI ? 5000 : 20000; + +describe("handleNewBooking", () => { + setupAndTeardown(); + + describe("Reschedule", () => { + test( + `should rechedule an existing booking successfully with Cal Video(Daily Video) + 1. Should cancel the existing booking + 2. Should create a new booking in the database + 3. Should send emails to the booker as well as organizer + 4. Should trigger BOOKING_RESCHEDULED webhook + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + bookings: [ + { + uid: uidOfBookingToBeRescheduled, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + credentialId: undefined, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + const videoMock = mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "MOCK_ID", + }, + update: { + uid: "UPDATED_MOCK_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + rescheduleUid: uidOfBookingToBeRescheduled, + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:15:00.000Z`, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + const previousBooking = await prismaMock.booking.findUnique({ + where: { + uid: uidOfBookingToBeRescheduled, + }, + }); + + logger.silly({ + previousBooking, + allBookings: await prismaMock.booking.findMany(), + }); + + // Expect previous booking to be cancelled + await expectBookingToBeInDatabase({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: uidOfBookingToBeRescheduled, + status: BookingStatus.CANCELLED, + }); + + expect(previousBooking?.status).toBe(BookingStatus.CANCELLED); + /** + * Booking Time should be new time + */ + expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`); + expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`); + + await expectBookingInDBToBeRescheduledFromTo({ + from: { + uid: uidOfBookingToBeRescheduled, + }, + to: { + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + }, + ], + }, + }); + + expectWorkflowToBeTriggered(); + + expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, { + calEvent: { + location: "http://mock-dailyvideo.example.com", + }, + bookingRef: { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + }); + + expectSuccessfulCalendarEventUpdationInCalendar(calendarMock, { + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + calEvent: { + videoCallData: expect.objectContaining({ + url: "http://mock-dailyvideo.example.com", + }), + }, + uid: "MOCK_ID", + }); + + expectSuccessfulBookingRescheduledEmails({ + booker, + organizer, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + expectBookingRescheduledWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + timeout + ); + test( + `should rechedule a booking successfully and update the event in the same externalCalendarId as was used in the booking earlier. + 1. Should cancel the existing booking + 2. Should create a new booking in the database + 3. Should send emails to the booker as well as organizer + 4. Should trigger BOOKING_RESCHEDULED webhook + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@example.com", + }, + }, + ], + bookings: [ + { + uid: uidOfBookingToBeRescheduled, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "existing-event-type@example.com", + credentialId: undefined, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + const videoMock = mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "MOCK_ID", + }, + update: { + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + uid: "UPDATED_MOCK_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + rescheduleUid: uidOfBookingToBeRescheduled, + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:15:00.000Z`, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + /** + * Booking Time should be new time + */ + expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`); + expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`); + + await expectBookingInDBToBeRescheduledFromTo({ + from: { + uid: uidOfBookingToBeRescheduled, + }, + to: { + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "existing-event-type@example.com", + }, + ], + }, + }); + + expectWorkflowToBeTriggered(); + + expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, { + calEvent: { + location: "http://mock-dailyvideo.example.com", + }, + bookingRef: { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + }); + + // updateEvent uses existing booking's externalCalendarId to update the event in calendar. + // and not the event-type's organizer's which is event-type-1@example.com + expectSuccessfulCalendarEventUpdationInCalendar(calendarMock, { + externalCalendarId: "existing-event-type@example.com", + calEvent: { + location: "http://mock-dailyvideo.example.com", + }, + uid: "MOCK_ID", + }); + + expectSuccessfulBookingRescheduledEmails({ + booker, + organizer, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + expectBookingRescheduledWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + timeout + ); + + test( + `an error in updating a calendar event should not stop the rescheduling - Current behaviour is wrong as the booking is resheduled but no-one is notified of it`, + async ({}) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + }, + }); + const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + bookings: [ + { + uid: uidOfBookingToBeRescheduled, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "ORIGINAL_BOOKING_UID", + meetingId: "ORIGINAL_MEETING_ID", + meetingPassword: "ORIGINAL_MEETING_PASSWORD", + meetingUrl: "https://ORIGINAL_MEETING_URL", + externalCalendarId: "existing-event-type@example.com", + credentialId: undefined, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + const _calendarMock = mockCalendarToCrashOnUpdateEvent("googlecalendar"); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + rescheduleUid: uidOfBookingToBeRescheduled, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + await expectBookingInDBToBeRescheduledFromTo({ + from: { + uid: uidOfBookingToBeRescheduled, + }, + to: { + description: "", + location: "New York", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + references: [ + { + type: appStoreMetadata.googlecalendar.type, + // A reference is still created in case of event creation failure, with nullish values. Not sure what's the purpose for this. + uid: "ORIGINAL_BOOKING_UID", + meetingId: "ORIGINAL_MEETING_ID", + meetingPassword: "ORIGINAL_MEETING_PASSWORD", + meetingUrl: "https://ORIGINAL_MEETING_URL", + }, + ], + }, + }); + + expectWorkflowToBeTriggered(); + + // FIXME: We should send Broken Integration emails on calendar event updation failure + // expectBrokenIntegrationEmails({ booker, organizer, emails }); + + expectBookingRescheduledWebhookToHaveBeenFired({ + booker, + organizer, + location: "New York", + subscriberUrl: "http://my-webhook.example.com", + }); + }, + timeout + ); + }); +}); diff --git a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts new file mode 100644 index 0000000000..09e98d14dd --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts @@ -0,0 +1,1086 @@ +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { describe, expect } from "vitest"; + +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { SchedulingType } from "@calcom/prisma/enums"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { test } from "@calcom/web/test/fixtures/fixtures"; +import { + createBookingScenario, + getGoogleCalendarCredential, + TestData, + getOrganizer, + getBooker, + getScenarioData, + mockSuccessfulVideoMeetingCreation, + mockCalendarToHaveNoBusySlots, + Timezones, + getDate, + getExpectedCalEventForBookingRequest, + BookingLocations, + getZoomAppCredential, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { + expectWorkflowToBeTriggered, + expectSuccessfulBookingCreationEmails, + expectBookingToBeInDatabase, + expectBookingCreatedWebhookToHaveBeenFired, + expectSuccessfulCalendarEventCreationInCalendar, + expectSuccessfulVideoMeetingCreation, +} from "@calcom/web/test/utils/bookingScenario/expects"; + +import { createMockNextJsRequest } from "../lib/createMockNextJsRequest"; +import { getMockRequestDataForBooking } from "../lib/getMockRequestDataForBooking"; +import { setupAndTeardown } from "../lib/setupAndTeardown"; + +export type CustomNextApiRequest = NextApiRequest & Request; + +export type CustomNextApiResponse = NextApiResponse & Response; +// Local test runs sometime gets too slow +const timeout = process.env.CI ? 5000 : 20000; +describe("handleNewBooking", () => { + setupAndTeardown(); + + describe("Team Events", () => { + describe("Collective Assignment", () => { + describe("When there is no schedule set on eventType - Hosts schedules would be used", () => { + test( + `succesfully creates a booking when all the hosts are free as per their schedules + - Destination calendars for event-type and non-first hosts are used to create calendar events + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + // So, that it picks the first schedule from the list + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + // Has Evening shift + schedules: [TestData.schedules.IstEveningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + // So, that it picks the first schedule from the list + defaultScheduleId: null, + // Has morning shift with some overlap with morning shift + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + schedulingType: SchedulingType.COLLECTIVE, + length: 45, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + // Try booking the first available free timeslot in both the users' schedules + start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + await expectBookingToBeInDatabase({ + description: "", + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: TestData.apps["google-calendar"].type, + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + + expectWorkflowToBeTriggered(); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + destinationCalendars: [ + { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + ], + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); + + expectSuccessfulBookingCreationEmails({ + booker, + organizer, + otherTeamMembers, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + timeout + ); + + test( + `rejects a booking when even one of the hosts is busy`, + async ({}) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + // So, that it picks the first schedule from the list + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + // Has Evening shift + schedules: [TestData.schedules.IstEveningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + // So, that it picks the first schedule from the list + defaultScheduleId: null, + // Has morning shift with some overlap with morning shift + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + schedulingType: SchedulingType.COLLECTIVE, + length: 45, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + // Try booking the first available free timeslot in both the users' schedules + start: `${getDate({ dateIncrement: 1 }).dateString}T09:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T09:15:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(async () => { + await handleNewBooking(req); + }).rejects.toThrowError("Some of the hosts are unavailable for booking"); + }, + timeout + ); + }); + + describe("When there is a schedule set on eventType - Event Type common schedule would be used", () => { + test( + `succesfully creates a booking when the users are available as per the common schedule selected in the event-type + - Destination calendars for event-type and non-first hosts are used to create calendar events + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + // No user schedules are here + schedules: [], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + // No user schedules are here + schedules: [], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + schedulingType: SchedulingType.COLLECTIVE, + length: 45, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + // Common schedule is the morning shift + schedule: TestData.schedules.IstMorningShift, + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + // Try booking the first available free timeslot in both the users' schedules + start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + await expectBookingToBeInDatabase({ + description: "", + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: TestData.apps["google-calendar"].type, + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + + expectWorkflowToBeTriggered(); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + destinationCalendars: [ + { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + ], + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); + + expectSuccessfulBookingCreationEmails({ + booker, + organizer, + otherTeamMembers, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + timeout + ); + + test( + `rejects a booking when the timeslot isn't within the common schedule`, + async ({}) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + // So, that it picks the first schedule from the list + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + schedules: [], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + // So, that it picks the first schedule from the list + defaultScheduleId: null, + schedules: [], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + schedulingType: SchedulingType.COLLECTIVE, + length: 45, + schedule: TestData.schedules.IstMorningShift, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${getDate({ dateIncrement: 1 }).dateString}T03:30:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T03:45:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(async () => { + await handleNewBooking(req); + }).rejects.toThrowError("No available users found."); + }, + timeout + ); + }); + + test( + `When Cal Video is the location, it uses global instance credentials and createMeeting is called for it`, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + defaultScheduleId: 1001, + email: "other-team-member-1@example.com", + id: 102, + schedules: [{ ...TestData.schedules.IstWorkHours, id: 1001 }], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + // Even though Daily Video credential isn't here, it would still work because it's a globally installed app and credentials are available on instance level + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + const { eventTypes } = await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + schedulingType: SchedulingType.COLLECTIVE, + length: 45, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + const videoMock = mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T05:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + await expectBookingToBeInDatabase({ + description: "", + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + + expectWorkflowToBeTriggered(); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + destinationCalendars: [ + { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + ], + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); + + expectSuccessfulVideoMeetingCreation(videoMock, { + credential: expect.objectContaining({ + appId: "daily-video", + key: { + apikey: "MOCK_DAILY_API_KEY", + }, + }), + calEvent: expect.objectContaining( + getExpectedCalEventForBookingRequest({ + bookingRequest: mockBookingData, + eventType: eventTypes[0], + }) + ), + }); + + expectSuccessfulBookingCreationEmails({ + booker, + organizer, + otherTeamMembers, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + timeout + ); + + test( + `When Zoom is the location, it uses credentials of the first host and createMeeting is called for it.`, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + defaultScheduleId: 1001, + email: "other-team-member-1@example.com", + id: 102, + schedules: [ + { + ...TestData.schedules.IstWorkHours, + // Specify an ID directly here because we want to be able to use that ID in defaultScheduleId above. + id: 1001, + }, + ], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [ + { + id: 2, + ...getGoogleCalendarCredential(), + }, + { + id: 1, + ...getZoomAppCredential(), + }, + ], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + const { eventTypes } = await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + schedulingType: SchedulingType.COLLECTIVE, + length: 45, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + locations: [ + { + type: BookingLocations.ZoomVideo, + credentialId: 1, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["zoomvideo"]], + }) + ); + + const videoMock = mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "zoomvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-zoomvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T05:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.ZoomVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + await expectBookingToBeInDatabase({ + description: "", + location: BookingLocations.ZoomVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: TestData.apps.zoomvideo.type, + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-zoomvideo.example.com/meeting-1", + }, + { + type: TestData.apps["google-calendar"].type, + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + + expectWorkflowToBeTriggered(); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + destinationCalendars: [ + { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + ], + videoCallUrl: "http://mock-zoomvideo.example.com/meeting-1", + }); + + expectSuccessfulVideoMeetingCreation(videoMock, { + credential: expect.objectContaining({ + appId: TestData.apps.zoomvideo.slug, + key: expect.objectContaining({ + access_token: "ACCESS_TOKEN", + refresh_token: "REFRESH_TOKEN", + token_type: "Bearer", + }), + }), + calEvent: expect.objectContaining( + getExpectedCalEventForBookingRequest({ + bookingRequest: mockBookingData, + eventType: eventTypes[0], + }) + ), + }); + + expectSuccessfulBookingCreationEmails({ + booker, + organizer, + otherTeamMembers, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.ZoomVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `http://mock-zoomvideo.example.com/meeting-1`, + }); + }, + timeout + ); + }); + + test.todo("Round Robin booking"); + }); + + describe("Team Plus Paid Events", () => { + test.todo("Collective event booking"); + test.todo("Round Robin booking"); + }); + test.todo("Calendar and video Apps installed on a Team Account"); +}); diff --git a/packages/lib/piiFreeData.ts b/packages/lib/piiFreeData.ts index 7e8f838676..1df51ed8b9 100644 --- a/packages/lib/piiFreeData.ts +++ b/packages/lib/piiFreeData.ts @@ -3,6 +3,14 @@ import type { Credential, SelectedCalendar, DestinationCalendar } from "@prisma/ import type { EventType } from "@calcom/prisma/client"; import type { CalendarEvent } from "@calcom/types/Calendar"; +function getBooleanStatus(val: unknown) { + if (process.env.NODE_ENV === "production") { + return `PiiFree:${!!val}`; + } else { + return val; + } +} + export function getPiiFreeCalendarEvent(calEvent: CalendarEvent) { return { eventTypeId: calEvent.eventTypeId, @@ -16,12 +24,13 @@ export function getPiiFreeCalendarEvent(calEvent: CalendarEvent) { recurrence: calEvent.recurrence, requiresConfirmation: calEvent.requiresConfirmation, uid: calEvent.uid, + conferenceCredentialId: calEvent.conferenceCredentialId, iCalUID: calEvent.iCalUID, /** * Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not */ // Not okay to have title which can have Booker and Organizer names - title: !!calEvent.title, + title: getBooleanStatus(calEvent.title), // .... Add all other props here that we don't want to be logged. It prevents those properties from being logged accidentally }; } @@ -44,7 +53,7 @@ export function getPiiFreeBooking(booking: { * Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not */ // Not okay to have title which can have Booker and Organizer names - title: !!booking.title, + title: getBooleanStatus(booking.title), // .... Add all other props here that we don't want to be logged. It prevents those properties from being logged accidentally }; } @@ -60,7 +69,7 @@ export function getPiiFreeCredential(credential: Partial) { /** * Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not */ - key: !!credential.key, + key: getBooleanStatus(credential.key), }; } @@ -82,7 +91,7 @@ export function getPiiFreeDestinationCalendar(destinationCalendar: Partial Date: Tue, 10 Oct 2023 14:48:53 +0530 Subject: [PATCH 021/120] fix: add prisma import (#11781) --- .../trpc/server/routers/viewer/teams/resendInvitation.handler.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts b/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts index d32e7f03e9..a45d2d3905 100644 --- a/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts @@ -1,6 +1,7 @@ import { sendTeamInviteEmail } from "@calcom/emails"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { getTranslation } from "@calcom/lib/server/i18n"; +import { prisma } from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import { TRPCError } from "@trpc/server"; From f9af15175d740c1ec948a66b38fe2394c5962d37 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Tue, 10 Oct 2023 16:30:25 +0530 Subject: [PATCH 022/120] fix: subteam avatar flciker (#11773) --- apps/web/pages/team/[slug].tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/web/pages/team/[slug].tsx b/apps/web/pages/team/[slug].tsx index 41f4046447..c6e68d5310 100644 --- a/apps/web/pages/team/[slug].tsx +++ b/apps/web/pages/team/[slug].tsx @@ -5,7 +5,6 @@ import { usePathname } from "next/navigation"; import { useEffect } from "react"; import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe"; -import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription"; import { getFeatureFlagMap } from "@calcom/features/flags/server/utils"; @@ -33,7 +32,13 @@ import { ssrInit } from "@server/lib/ssr"; export type PageProps = inferSSRProps; -function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }: PageProps) { +function TeamPage({ + team, + isUnpublished, + markdownStrippedBio, + isValidOrgDomain, + currentOrgDomain, +}: PageProps) { useTheme(team.theme); const routerQuery = useRouterQuery(); const pathname = usePathname(); @@ -44,7 +49,6 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain } const teamName = team.name || "Nameless Team"; const isBioEmpty = !team.bio || !team.bio.replace("


", "").length; const metadata = teamMetadataSchema.parse(team.metadata); - const orgBranding = useOrgBranding(); useEffect(() => { telemetry.event( @@ -182,8 +186,8 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain } trpcState: ssr.dehydrate(), markdownStrippedBio, isValidOrgDomain, + currentOrgDomain, }, } as const; }; From b4c6388ce041324ad19084485a30c45480221586 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Tue, 10 Oct 2023 12:05:20 +0100 Subject: [PATCH 023/120] feat: overlay your calendar (#11693) * Init header + login modal component * Add calendar settings for authed user * Local storage and using query params for toggle * Toggle connect screen if query param present and no session * Local storage + store + way more than that should be in single commit * Display busy events on weekly view * Confirm booking slot of overlap exists * use chevron right when on column view * Show hover card - overlapping date times * Invalidate on switch * FIx clearing local storage when you login to another account * Force re-render on url state (atom quirks) * Add loading screen * Add dialog close * Remove extra grid config * Translations * [WIP] - tests * fix: google calendar busy times (#11696) Co-authored-by: CarinaWolli * New Crowdin translations by Github Action * fix: rescheduled value DB update on reschedule and insights view cancelleds (#11474) * v3.3.5 * fix minutes string (#11703) Co-authored-by: CarinaWolli * Regenerated yarn.lock * Add error component + loader * await tests * disable tests - add note * Refactor to include selected time * use no-scrollbar * Fix i18n * Fix tablet toolbar * overflow + i18n * Export empty object as test is TODO * Uses booker timezone * Fix hiding switch too early * Handle selected timezone * Fix timezone issues * Fix timezone issues --------- Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: CarinaWolli Co-authored-by: Crowdin Bot Co-authored-by: alannnc Co-authored-by: Alex van Andel Co-authored-by: Peer Richelsen Co-authored-by: Peer Richelsen --- apps/web/playwright/overlay-calendar.e2e.ts | 39 ++++ apps/web/public/static/locales/en/common.json | 4 + .../Booker/components/AvailableTimeSlots.tsx | 2 +- .../bookings/Booker/components/EventMeta.tsx | 9 + .../bookings/Booker/components/Header.tsx | 9 +- .../Booker/components/LargeCalendar.tsx | 27 ++- .../OverlayCalendarContainer.tsx | 154 +++++++++++++ .../OverlayCalendarContinueModal.tsx | 47 ++++ .../OverlayCalendarSettingsModal.tsx | 155 +++++++++++++ .../components/OverlayCalendar/store.ts | 15 ++ .../Booker/components/hooks/useLocalSet.tsx | 64 +++++ packages/features/bookings/Booker/config.ts | 11 + .../bookings/components/AvailableTimes.tsx | 218 ++++++++++++++---- .../lib/useCheckOverlapWithOverlay.tsx | 41 ++++ .../routers/viewer/availability/_router.tsx | 20 +- .../availability/calendarOverlay.handler.ts | 102 ++++++++ .../availability/calendarOverlay.schema.ts | 15 ++ 17 files changed, 877 insertions(+), 55 deletions(-) create mode 100644 apps/web/playwright/overlay-calendar.e2e.ts create mode 100644 packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx create mode 100644 packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContinueModal.tsx create mode 100644 packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx create mode 100644 packages/features/bookings/Booker/components/OverlayCalendar/store.ts create mode 100644 packages/features/bookings/Booker/components/hooks/useLocalSet.tsx create mode 100644 packages/features/bookings/lib/useCheckOverlapWithOverlay.tsx create mode 100644 packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts create mode 100644 packages/trpc/server/routers/viewer/availability/calendarOverlay.schema.ts diff --git a/apps/web/playwright/overlay-calendar.e2e.ts b/apps/web/playwright/overlay-calendar.e2e.ts new file mode 100644 index 0000000000..803f772fb3 --- /dev/null +++ b/apps/web/playwright/overlay-calendar.e2e.ts @@ -0,0 +1,39 @@ +export {}; +// TODO: @sean - I can't run E2E locally - causing me a lot of pain to try and debug. +// Will tackle in follow up once i reset my system. +// test.describe("User can overlay their calendar", async () => { +// test.afterAll(async ({ users }) => { +// await users.deleteAll(); +// }); +// test("Continue with Cal.com flow", async ({ page, users }) => { +// await users.create({ +// username: "overflow-user-test", +// }); +// await test.step("toggles overlay without a session", async () => { +// await page.goto("/overflow-user-test/30-min"); +// const switchLocator = page.locator(`[data-testid=overlay-calendar-switch]`); +// await switchLocator.click(); +// const continueWithCalCom = page.locator(`[data-testid=overlay-calendar-continue-button]`); +// await expect(continueWithCalCom).toBeVisible(); +// await continueWithCalCom.click(); +// }); +// // log in trail user +// await test.step("Log in and return to booking page", async () => { +// const user = await users.create(); +// await user.login(); +// // Expect page to be redirected to the test users booking page +// await page.waitForURL("/overflow-user-test/30-min"); +// }); +// await test.step("Expect settings cog to be visible when session exists", async () => { +// const settingsCog = page.locator(`[data-testid=overlay-calendar-settings-button]`); +// await expect(settingsCog).toBeVisible(); +// }); +// await test.step("Settings should so no calendars connected", async () => { +// const settingsCog = page.locator(`[data-testid=overlay-calendar-settings-button]`); +// await settingsCog.click(); +// await page.waitForLoadState("networkidle"); +// const emptyScreenLocator = page.locator(`[data-testid=empty-screen]`); +// await expect(emptyScreenLocator).toBeVisible(); +// }); +// }); +// }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index db4ff60a2d..b96f728d13 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -268,6 +268,7 @@ "set_availability": "Set your availability", "availability_settings": "Availability Settings", "continue_without_calendar": "Continue without calendar", + "continue_with": "Continue with {{appName}}", "connect_your_calendar": "Connect your calendar", "connect_your_video_app": "Connect your video apps", "connect_your_video_app_instructions": "Connect your video apps to use them on your event types.", @@ -2085,5 +2086,8 @@ "copy_client_secret_info": "After copying the secret you won't be able to view it anymore", "add_new_client": "Add new Client", "this_app_is_not_setup_already": "This app has not been setup yet", + "overlay_my_calendar":"Overlay my calendar", + "overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.", + "view_overlay_calendar_events":"View your calendar events to prevent clashed booking.", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx index 8d34240b8d..f2d40e3654 100644 --- a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx +++ b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx @@ -133,7 +133,7 @@ export const AvailableTimeSlots = ({ : slotsPerDay.length > 0 && slotsPerDay.map((slots) => ( import("@calcom/ui").then((mod) => mod.Time export const EventMeta = () => { const { setTimezone, timeFormat, timezone } = useTimePreferences(); const selectedDuration = useBookerStore((state) => state.selectedDuration); + const setSelectedDuration = useBookerStore((state) => state.setSelectedDuration); const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot); const bookerState = useBookerStore((state) => state.state); const bookingData = useBookerStore((state) => state.bookingData); @@ -36,6 +38,13 @@ export const EventMeta = () => { const isEmbed = useIsEmbed(); const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false; + useEffect(() => { + if (!selectedDuration && event?.length) { + setSelectedDuration(event.length); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [event?.length, selectedDuration]); + if (hideEventTypeDetails) { return null; } diff --git a/packages/features/bookings/Booker/components/Header.tsx b/packages/features/bookings/Booker/components/Header.tsx index 5d65575129..d9942547ad 100644 --- a/packages/features/bookings/Booker/components/Header.tsx +++ b/packages/features/bookings/Booker/components/Header.tsx @@ -11,6 +11,7 @@ import { Calendar, Columns, Grid } from "@calcom/ui/components/icon"; import { TimeFormatToggle } from "../../components/TimeFormatToggle"; import { useBookerStore } from "../store"; import type { BookerLayout } from "../types"; +import { OverlayCalendarContainer } from "./OverlayCalendar/OverlayCalendarContainer"; export function Header({ extraDays, @@ -56,7 +57,12 @@ export function Header({ // In month view we only show the layout toggle. if (isMonthView) { - return ; + return ( +
+ + +
+ ); } const endDate = selectedDate.add(layout === BookerLayouts.COLUMN_VIEW ? extraDays : extraDays - 1, "days"); @@ -113,6 +119,7 @@ export function Header({
+
diff --git a/packages/features/bookings/Booker/components/LargeCalendar.tsx b/packages/features/bookings/Booker/components/LargeCalendar.tsx index 021f53180c..b9684912bc 100644 --- a/packages/features/bookings/Booker/components/LargeCalendar.tsx +++ b/packages/features/bookings/Booker/components/LargeCalendar.tsx @@ -1,20 +1,25 @@ -import { useMemo } from "react"; +import { useMemo, useEffect } from "react"; import dayjs from "@calcom/dayjs"; import { Calendar } from "@calcom/features/calendars/weeklyview"; +import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events"; import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state"; import { useBookerStore } from "../store"; import { useEvent, useScheduleForEvent } from "../utils/event"; +import { getQueryParam } from "../utils/query-param"; +import { useOverlayCalendarStore } from "./OverlayCalendar/store"; export const LargeCalendar = ({ extraDays }: { extraDays: number }) => { const selectedDate = useBookerStore((state) => state.selectedDate); const date = selectedDate || dayjs().format("YYYY-MM-DD"); const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot); const selectedEventDuration = useBookerStore((state) => state.selectedDuration); + const overlayEvents = useOverlayCalendarStore((state) => state.overlayBusyDates); const schedule = useScheduleForEvent({ prefetchNextMonth: !!extraDays && dayjs(date).month() !== dayjs(date).add(extraDays, "day").month(), }); + const displayOverlay = getQueryParam("overlayCalendar") === "true"; const event = useEvent(); const eventDuration = selectedEventDuration || event?.data?.length || 30; @@ -39,6 +44,24 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => { .add(extraDays - 1, "day") .toDate(); + // HACK: force rerender when overlay events change + // Sine we dont use react router here we need to force rerender (ATOM SUPPORT) + // eslint-disable-next-line @typescript-eslint/no-empty-function + useEffect(() => {}, [displayOverlay]); + + const overlayEventsForDate = useMemo(() => { + if (!overlayEvents || !displayOverlay) return []; + return overlayEvents.map((event, id) => { + return { + id, + start: dayjs(event.start).toDate(), + end: dayjs(event.end).toDate(), + title: "Busy", + status: "ACCEPTED", + } as CalendarEvent; + }); + }, [overlayEvents, displayOverlay]); + return (
{ availableTimeslots={availableSlots} startHour={0} endHour={23} - events={[]} + events={overlayEventsForDate} startDate={startDate} endDate={endDate} onEmptyCellClick={(date) => setSelectedTimeslot(date.toISOString())} diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx new file mode 100644 index 0000000000..7603d82795 --- /dev/null +++ b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx @@ -0,0 +1,154 @@ +import { useSession } from "next-auth/react"; +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { useState, useCallback, useEffect } from "react"; + +import dayjs from "@calcom/dayjs"; +import { useTimePreferences } from "@calcom/features/bookings/lib"; +import { classNames } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Button, Switch } from "@calcom/ui"; +import { Settings } from "@calcom/ui/components/icon"; + +import { useBookerStore } from "../../store"; +import { OverlayCalendarContinueModal } from "../OverlayCalendar/OverlayCalendarContinueModal"; +import { OverlayCalendarSettingsModal } from "../OverlayCalendar/OverlayCalendarSettingsModal"; +import { useLocalSet } from "../hooks/useLocalSet"; +import { useOverlayCalendarStore } from "./store"; + +export function OverlayCalendarContainer() { + const { t } = useLocale(); + const [continueWithProvider, setContinueWithProvider] = useState(false); + const [calendarSettingsOverlay, setCalendarSettingsOverlay] = useState(false); + const { data: session } = useSession(); + const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates); + + const layout = useBookerStore((state) => state.layout); + const selectedDate = useBookerStore((state) => state.selectedDate); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { timezone } = useTimePreferences(); + + // Move this to a hook + const { set, clearSet } = useLocalSet<{ + credentialId: number; + externalId: string; + }>("toggledConnectedCalendars", []); + const overlayCalendarQueryParam = searchParams.get("overlayCalendar"); + + const { data: overlayBusyDates } = trpc.viewer.availability.calendarOverlay.useQuery( + { + loggedInUsersTz: timezone || "Europe/London", + dateFrom: selectedDate, + dateTo: selectedDate, + calendarsToLoad: Array.from(set).map((item) => ({ + credentialId: item.credentialId, + externalId: item.externalId, + })), + }, + { + enabled: !!session && set.size > 0 && overlayCalendarQueryParam === "true", + onError: () => { + clearSet(); + }, + } + ); + + useEffect(() => { + if (overlayBusyDates) { + const nowDate = dayjs(); + const usersTimezoneDate = nowDate.tz(timezone); + + const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60; + + const offsettedArray = overlayBusyDates.map((item) => { + return { + ...item, + start: dayjs(item.start).add(offset, "hours").toDate(), + end: dayjs(item.end).add(offset, "hours").toDate(), + }; + }); + setOverlayBusyDates(offsettedArray); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [overlayBusyDates]); + + // Toggle query param for overlay calendar + const toggleOverlayCalendarQueryParam = useCallback( + (state: boolean) => { + const current = new URLSearchParams(Array.from(searchParams.entries())); + if (state) { + current.set("overlayCalendar", "true"); + } else { + current.delete("overlayCalendar"); + } + // cast to string + const value = current.toString(); + const query = value ? `?${value}` : ""; + router.push(`${pathname}${query}`); + }, + [searchParams, pathname, router] + ); + + /** + * If a user is not logged in and the overlay calendar query param is true, + * show the continue modal so they can login / create an account + */ + useEffect(() => { + if (!session && overlayCalendarQueryParam === "true") { + toggleOverlayCalendarQueryParam(false); + setContinueWithProvider(true); + } + }, [session, overlayCalendarQueryParam, toggleOverlayCalendarQueryParam]); + + return ( + <> +
+
+ { + if (!session) { + setContinueWithProvider(state); + } else { + toggleOverlayCalendarQueryParam(state); + } + }} + /> + +
+ {session && ( +
+ { + setContinueWithProvider(val); + }} + /> + { + setCalendarSettingsOverlay(val); + }} + /> + + ); +} diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContinueModal.tsx b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContinueModal.tsx new file mode 100644 index 0000000000..68793fa4a1 --- /dev/null +++ b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContinueModal.tsx @@ -0,0 +1,47 @@ +import { CalendarSearch } from "lucide-react"; +import { useRouter } from "next/navigation"; + +import { APP_NAME } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button, Dialog, DialogContent, DialogFooter } from "@calcom/ui"; + +interface IOverlayCalendarContinueModalProps { + open?: boolean; + onClose?: (state: boolean) => void; +} + +export function OverlayCalendarContinueModal(props: IOverlayCalendarContinueModalProps) { + const router = useRouter(); + const { t } = useLocale(); + return ( + <> + + +
+ +
+ + {/* Agh modal hacks */} + <> + +
+
+ + ); +} diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx new file mode 100644 index 0000000000..24ccc80a73 --- /dev/null +++ b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx @@ -0,0 +1,155 @@ +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Fragment } from "react"; + +import { classNames } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { + Alert, + Dialog, + DialogContent, + EmptyScreen, + ListItem, + ListItemText, + ListItemTitle, + Switch, + DialogClose, + SkeletonContainer, + SkeletonText, +} from "@calcom/ui"; +import { Calendar } from "@calcom/ui/components/icon"; + +import { useLocalSet } from "../hooks/useLocalSet"; +import { useOverlayCalendarStore } from "./store"; + +interface IOverlayCalendarContinueModalProps { + open?: boolean; + onClose?: (state: boolean) => void; +} + +const SkeletonLoader = () => { + return ( + +
+ + + + +
+
+ ); +}; + +export function OverlayCalendarSettingsModal(props: IOverlayCalendarContinueModalProps) { + const utils = trpc.useContext(); + const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates); + const { data, isLoading } = trpc.viewer.connectedCalendars.useQuery(undefined, { + enabled: !!props.open, + }); + const { toggleValue, hasItem } = useLocalSet<{ + credentialId: number; + externalId: string; + }>("toggledConnectedCalendars", []); + + const router = useRouter(); + const { t } = useLocale(); + return ( + <> + + +
+ {isLoading ? ( + + ) : ( + <> + {data?.connectedCalendars.length === 0 ? ( + router.push("/apps/categories/calendar")} + /> + ) : ( + <> + {data?.connectedCalendars.map((item) => ( + + {item.error && !item.calendars && ( + + )} + {item?.error === undefined && item.calendars && ( + +
+ { + // eslint-disable-next-line @next/next/no-img-element + item.integration.logo && ( + {item.integration.title} + ) + } +
+ + + {item.integration.name || item.integration.title} + + + {item.primary.email} +
+
+
+
    + {item.calendars.map((cal, index) => { + const id = cal.integrationTitle ?? `calendar-switch-${index}`; + return ( +
  • + { + toggleValue({ + credentialId: item.credentialId, + externalId: cal.externalId, + }); + setOverlayBusyDates([]); + utils.viewer.availability.calendarOverlay.reset(); + }} + /> + +
  • + ); + })} +
+
+
+ )} +
+ ))} + + )} + + )} +
+ +
+ {t("done")} +
+
+
+ + ); +} diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/store.ts b/packages/features/bookings/Booker/components/OverlayCalendar/store.ts new file mode 100644 index 0000000000..1d9fd90b55 --- /dev/null +++ b/packages/features/bookings/Booker/components/OverlayCalendar/store.ts @@ -0,0 +1,15 @@ +import { create } from "zustand"; + +import type { EventBusyDate } from "@calcom/types/Calendar"; + +interface IOverlayCalendarStore { + overlayBusyDates: EventBusyDate[] | undefined; + setOverlayBusyDates: (busyDates: EventBusyDate[]) => void; +} + +export const useOverlayCalendarStore = create((set) => ({ + overlayBusyDates: undefined, + setOverlayBusyDates: (busyDates: EventBusyDate[]) => { + set({ overlayBusyDates: busyDates }); + }, +})); diff --git a/packages/features/bookings/Booker/components/hooks/useLocalSet.tsx b/packages/features/bookings/Booker/components/hooks/useLocalSet.tsx new file mode 100644 index 0000000000..3bcc9dad14 --- /dev/null +++ b/packages/features/bookings/Booker/components/hooks/useLocalSet.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; + +export interface HasExternalId { + externalId: string; +} + +export function useLocalSet(key: string, initialValue: T[]) { + const [set, setSet] = useState>(() => { + const storedValue = localStorage.getItem(key); + return storedValue ? new Set(JSON.parse(storedValue)) : new Set(initialValue); + }); + + useEffect(() => { + localStorage.setItem(key, JSON.stringify(Array.from(set))); + }, [key, set]); + + const addValue = (value: T) => { + setSet((prevSet) => new Set(prevSet).add(value)); + }; + + const removeById = (id: string) => { + setSet((prevSet) => { + const updatedSet = new Set(prevSet); + updatedSet.forEach((item) => { + if (item.externalId === id) { + updatedSet.delete(item); + } + }); + return updatedSet; + }); + }; + + const toggleValue = (value: T) => { + setSet((prevSet) => { + const updatedSet = new Set(prevSet); + let itemFound = false; + + updatedSet.forEach((item) => { + if (item.externalId === value.externalId) { + itemFound = true; + updatedSet.delete(item); + } + }); + + if (!itemFound) { + updatedSet.add(value); + } + + return updatedSet; + }); + }; + + const hasItem = (value: T) => { + return Array.from(set).some((item) => item.externalId === value.externalId); + }; + + const clearSet = () => { + setSet(() => new Set()); + // clear local storage too + localStorage.removeItem(key); + }; + + return { set, addValue, removeById, toggleValue, hasItem, clearSet }; +} diff --git a/packages/features/bookings/Booker/config.ts b/packages/features/bookings/Booker/config.ts index b3f537284f..516db66c94 100644 --- a/packages/features/bookings/Booker/config.ts +++ b/packages/features/bookings/Booker/config.ts @@ -28,6 +28,17 @@ export const fadeInUp = { transition: { ease: "easeInOut", delay: 0.1 }, }; +export const fadeInRight = { + variants: { + visible: { opacity: 1, x: 0 }, + hidden: { opacity: 0, x: -20 }, + }, + initial: "hidden", + exit: "hidden", + animate: "visible", + transition: { ease: "easeInOut", delay: 0.1 }, +}; + type ResizeAnimationConfig = { [key in BookerLayout]: { [key in BookerState | "default"]?: React.CSSProperties; diff --git a/packages/features/bookings/components/AvailableTimes.tsx b/packages/features/bookings/components/AvailableTimes.tsx index 29bc587255..e46e020a6e 100644 --- a/packages/features/bookings/components/AvailableTimes.tsx +++ b/packages/features/bookings/components/AvailableTimes.tsx @@ -1,4 +1,8 @@ -import { CalendarX2 } from "lucide-react"; +// We do not need to worry about importing framer-motion here as it is lazy imported in Booker. +import * as HoverCard from "@radix-ui/react-hover-card"; +import { AnimatePresence, m } from "framer-motion"; +import { CalendarX2, ChevronRight } from "lucide-react"; +import { useCallback, useState } from "react"; import dayjs from "@calcom/dayjs"; import type { Slots } from "@calcom/features/schedules"; @@ -7,17 +11,21 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Button, SkeletonText } from "@calcom/ui"; import { useBookerStore } from "../Booker/store"; +import { getQueryParam } from "../Booker/utils/query-param"; import { useTimePreferences } from "../lib"; +import { useCheckOverlapWithOverlay } from "../lib/useCheckOverlapWithOverlay"; import { SeatsAvailabilityText } from "./SeatsAvailabilityText"; +type TOnTimeSelect = ( + time: string, + attendees: number, + seatsPerTimeSlot?: number | null, + bookingUid?: string +) => void; + type AvailableTimesProps = { slots: Slots[string]; - onTimeSelect: ( - time: string, - attendees: number, - seatsPerTimeSlot?: number | null, - bookingUid?: string - ) => void; + onTimeSelect: TOnTimeSelect; seatsPerTimeSlot?: number | null; showAvailableSeatsCount?: boolean | null; showTimeFormatToggle?: boolean; @@ -25,6 +33,148 @@ type AvailableTimesProps = { selectedSlots?: string[]; }; +const SlotItem = ({ + slot, + seatsPerTimeSlot, + selectedSlots, + onTimeSelect, + showAvailableSeatsCount, +}: { + slot: Slots[string][number]; + seatsPerTimeSlot?: number | null; + selectedSlots?: string[]; + onTimeSelect: TOnTimeSelect; + showAvailableSeatsCount?: boolean | null; +}) => { + const { t } = useLocale(); + + const overlayCalendarToggled = getQueryParam("overlayCalendar") === "true"; + const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]); + const selectedDuration = useBookerStore((state) => state.selectedDuration); + const bookingData = useBookerStore((state) => state.bookingData); + const layout = useBookerStore((state) => state.layout); + const hasTimeSlots = !!seatsPerTimeSlot; + const computedDateWithUsersTimezone = dayjs.utc(slot.time).tz(timezone); + + const bookingFull = !!(hasTimeSlots && slot.attendees && slot.attendees >= seatsPerTimeSlot); + const isHalfFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5; + const isNearlyFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83; + const colorClass = isNearlyFull ? "bg-rose-600" : isHalfFull ? "bg-yellow-500" : "bg-emerald-400"; + + const nowDate = dayjs(); + const usersTimezoneDate = nowDate.tz(timezone); + + const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60; + + const { isOverlapping, overlappingTimeEnd, overlappingTimeStart } = useCheckOverlapWithOverlay( + computedDateWithUsersTimezone, + selectedDuration, + offset + ); + const [overlapConfirm, setOverlapConfirm] = useState(false); + + const onButtonClick = useCallback(() => { + if (!overlayCalendarToggled) { + onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid); + return; + } + if (isOverlapping && overlapConfirm) { + setOverlapConfirm(false); + return; + } + + if (isOverlapping && !overlapConfirm) { + setOverlapConfirm(true); + return; + } + if (!overlapConfirm) { + onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid); + } + }, [ + overlayCalendarToggled, + isOverlapping, + overlapConfirm, + onTimeSelect, + slot.time, + slot?.attendees, + slot.bookingUid, + seatsPerTimeSlot, + ]); + + return ( + +
+ + {overlapConfirm && isOverlapping && ( + + + + + + + + +
+
+

Busy

+
+

+ {overlappingTimeStart} - {overlappingTimeEnd} +

+
+
+
+
+ )} +
+
+ ); +}; + export const AvailableTimes = ({ slots, onTimeSelect, @@ -34,10 +184,7 @@ export const AvailableTimes = ({ className, selectedSlots, }: AvailableTimesProps) => { - const { t, i18n } = useLocale(); - const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]); - const bookingData = useBookerStore((state) => state.bookingData); - const hasTimeSlots = !!seatsPerTimeSlot; + const { t } = useLocale(); return (
@@ -50,45 +197,16 @@ export const AvailableTimes = ({

)} - - {slots.map((slot) => { - const bookingFull = !!(hasTimeSlots && slot.attendees && slot.attendees >= seatsPerTimeSlot); - const isHalfFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5; - const isNearlyFull = - slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83; - - const colorClass = isNearlyFull ? "bg-rose-600" : isHalfFull ? "bg-yellow-500" : "bg-emerald-400"; - return ( - - ); - })} + {slots.map((slot) => ( + + ))}
); diff --git a/packages/features/bookings/lib/useCheckOverlapWithOverlay.tsx b/packages/features/bookings/lib/useCheckOverlapWithOverlay.tsx new file mode 100644 index 0000000000..a1a3020da8 --- /dev/null +++ b/packages/features/bookings/lib/useCheckOverlapWithOverlay.tsx @@ -0,0 +1,41 @@ +import type { Dayjs } from "@calcom/dayjs"; +import dayjs from "@calcom/dayjs"; + +import { useOverlayCalendarStore } from "../Booker/components/OverlayCalendar/store"; + +function getCurrentTime(date: Date) { + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); + return `${hours}:${minutes}`; +} + +export function useCheckOverlapWithOverlay(start: Dayjs, selectedDuration: number | null, offset: number) { + const overlayBusyDates = useOverlayCalendarStore((state) => state.overlayBusyDates); + + let overlappingTimeStart: string | null = null; + let overlappingTimeEnd: string | null = null; + + const isOverlapping = + overlayBusyDates && + overlayBusyDates.some((busyDate) => { + const busyDateStart = dayjs(busyDate.start); + const busyDateEnd = dayjs(busyDate.end); + const selectedEndTime = dayjs(start.add(offset, "hours")).add(selectedDuration ?? 0, "minute"); + + const isOverlapping = + (selectedEndTime.isSame(busyDateStart) || selectedEndTime.isAfter(busyDateStart)) && + start.add(offset, "hours") < busyDateEnd && + selectedEndTime > busyDateStart; + + overlappingTimeStart = isOverlapping ? getCurrentTime(busyDateStart.toDate()) : null; + overlappingTimeEnd = isOverlapping ? getCurrentTime(busyDateEnd.toDate()) : null; + + return isOverlapping; + }); + + return { isOverlapping, overlappingTimeStart, overlappingTimeEnd } as { + isOverlapping: boolean; + overlappingTimeStart: string | null; + overlappingTimeEnd: string | null; + }; +} diff --git a/packages/trpc/server/routers/viewer/availability/_router.tsx b/packages/trpc/server/routers/viewer/availability/_router.tsx index 1084dc5dc7..12a2fbcfb0 100644 --- a/packages/trpc/server/routers/viewer/availability/_router.tsx +++ b/packages/trpc/server/routers/viewer/availability/_router.tsx @@ -1,5 +1,6 @@ import authedProcedure from "../../../procedures/authedProcedure"; import { router } from "../../../trpc"; +import { ZCalendarOverlayInputSchema } from "./calendarOverlay.schema"; import { scheduleRouter } from "./schedule/_router"; import { ZListTeamAvailaiblityScheme } from "./team/listTeamAvailability.schema"; import { ZUserInputSchema } from "./user.schema"; @@ -7,6 +8,7 @@ import { ZUserInputSchema } from "./user.schema"; type AvailabilityRouterHandlerCache = { list?: typeof import("./list.handler").listHandler; user?: typeof import("./user.handler").userHandler; + calendarOverlay?: typeof import("./calendarOverlay.handler").calendarOverlayHandler; listTeamAvailability?: typeof import("./team/listTeamAvailability.handler").listTeamAvailabilityHandler; }; @@ -60,6 +62,22 @@ export const availabilityRouter = router({ input, }); }), - schedule: scheduleRouter, + calendarOverlay: authedProcedure.input(ZCalendarOverlayInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.calendarOverlay) { + UNSTABLE_HANDLER_CACHE.calendarOverlay = await import("./calendarOverlay.handler").then( + (mod) => mod.calendarOverlayHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.calendarOverlay) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.calendarOverlay({ + ctx, + input, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts b/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts new file mode 100644 index 0000000000..3ac4cc8581 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts @@ -0,0 +1,102 @@ +import { getBusyCalendarTimes } from "@calcom/core/CalendarManager"; +import dayjs from "@calcom/dayjs"; +import type { EventBusyDate } from "@calcom/types/Calendar"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TCalendarOverlayInputSchema } from "./calendarOverlay.schema"; + +type ListOptions = { + ctx: { + user: NonNullable; + }; + input: TCalendarOverlayInputSchema; +}; + +export const calendarOverlayHandler = async ({ ctx, input }: ListOptions) => { + const { user } = ctx; + const { calendarsToLoad, dateFrom, dateTo } = input; + + if (!dateFrom || !dateTo) { + return [] as EventBusyDate[]; + } + + // get all unique credentialIds from calendarsToLoad + const uniqueCredentialIds = Array.from(new Set(calendarsToLoad.map((item) => item.credentialId))); + + // To call getCalendar we need + + // Ensure that the user has access to all of the credentialIds + const credentials = await prisma.credential.findMany({ + where: { + id: { + in: uniqueCredentialIds, + }, + userId: user.id, + }, + select: { + id: true, + type: true, + key: true, + userId: true, + teamId: true, + appId: true, + invalid: true, + user: { + select: { + email: true, + }, + }, + }, + }); + + if (credentials.length !== uniqueCredentialIds.length) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Unauthorized - These credentials do not belong to you", + }); + } + + const composedSelectedCalendars = calendarsToLoad.map((calendar) => { + const credential = credentials.find((item) => item.id === calendar.credentialId); + if (!credential) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Unauthorized - These credentials do not belong to you", + }); + } + return { + ...calendar, + userId: user.id, + integration: credential.type, + }; + }); + + // get all clanedar services + const calendarBusyTimes = await getBusyCalendarTimes( + "", + credentials, + dateFrom, + dateTo, + composedSelectedCalendars + ); + + // Convert to users timezone + + const userTimeZone = input.loggedInUsersTz; + const calendarBusyTimesConverted = calendarBusyTimes.map((busyTime) => { + const busyTimeStart = dayjs(busyTime.start); + const busyTimeEnd = dayjs(busyTime.end); + const busyTimeStartDate = busyTimeStart.tz(userTimeZone).toDate(); + const busyTimeEndDate = busyTimeEnd.tz(userTimeZone).toDate(); + + return { + ...busyTime, + start: busyTimeStartDate, + end: busyTimeEndDate, + } as EventBusyDate; + }); + + return calendarBusyTimesConverted; +}; diff --git a/packages/trpc/server/routers/viewer/availability/calendarOverlay.schema.ts b/packages/trpc/server/routers/viewer/availability/calendarOverlay.schema.ts new file mode 100644 index 0000000000..c424ef3bf0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/calendarOverlay.schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const ZCalendarOverlayInputSchema = z.object({ + loggedInUsersTz: z.string(), + dateFrom: z.string().nullable(), + dateTo: z.string().nullable(), + calendarsToLoad: z.array( + z.object({ + credentialId: z.number(), + externalId: z.string(), + }) + ), +}); + +export type TCalendarOverlayInputSchema = z.infer; From 778485b31dac5502c38efbf9839228a777bc7507 Mon Sep 17 00:00:00 2001 From: Greg Pabian <35925521+grzpab@users.noreply.github.com> Date: Tue, 10 Oct 2023 18:36:28 +0200 Subject: [PATCH 024/120] refactor: implementation of locale calculated server-side (#11534) --- apps/web/components/I18nLanguageHandler.tsx | 50 +------------------ apps/web/components/PageWrapper.tsx | 3 -- apps/web/lib/app-providers.tsx | 34 +++++++------ apps/web/lib/withLocale.tsx | 3 ++ apps/web/lib/withNonce.tsx | 19 ++++--- apps/web/pages/_app.tsx | 32 +++++++++++- apps/web/pages/_document.tsx | 25 ++++++++-- apps/web/pages/auth/forgot-password/[id].tsx | 4 +- apps/web/pages/auth/forgot-password/index.tsx | 4 +- apps/web/pages/auth/login.tsx | 3 +- .../web/pages/getting-started/[[...step]].tsx | 4 +- .../web/pages/settings/my-account/general.tsx | 6 ++- apps/web/server/lib/ssr.ts | 4 +- packages/features/auth/lib/getLocale.ts | 33 ++++++++++++ packages/lib/getLocaleFromRequest.ts | 22 -------- packages/trpc/server/createContext.ts | 4 +- .../server/middlewares/sessionMiddleware.ts | 2 +- .../routers/publicViewer/i18n.handler.ts | 7 --- 18 files changed, 139 insertions(+), 120 deletions(-) create mode 100644 apps/web/lib/withLocale.tsx create mode 100644 packages/features/auth/lib/getLocale.ts delete mode 100644 packages/lib/getLocaleFromRequest.ts diff --git a/apps/web/components/I18nLanguageHandler.tsx b/apps/web/components/I18nLanguageHandler.tsx index 2db3dcc792..29ffcd97d9 100644 --- a/apps/web/components/I18nLanguageHandler.tsx +++ b/apps/web/components/I18nLanguageHandler.tsx @@ -1,12 +1,7 @@ -import { lookup } from "bcp-47-match"; -import { useSession } from "next-auth/react"; -import { useTranslation } from "next-i18next"; -import { useEffect } from "react"; - import { CALCOM_VERSION } from "@calcom/lib/constants"; import { trpc } from "@calcom/trpc/react"; -function useViewerI18n(locale: string) { +export function useViewerI18n(locale: string) { return trpc.viewer.public.i18n.useQuery( { locale, CalComVersion: CALCOM_VERSION }, { @@ -19,46 +14,3 @@ function useViewerI18n(locale: string) { } ); } - -function useClientLocale(locales: string[]) { - const session = useSession(); - // If the user is logged in, use their locale - if (session.data?.user.locale) return session.data.user.locale; - // If the user is not logged in, use the browser locale - if (typeof window !== "undefined") { - // This is the only way I found to ensure the prefetched locale is used on first render - // FIXME: Find a better way to pick the best matching locale from the browser - return lookup(locales, window.navigator.language) || window.navigator.language; - } - // If the browser is not available, use English - return "en"; -} - -export function useClientViewerI18n(locales: string[]) { - const clientLocale = useClientLocale(locales); - return useViewerI18n(clientLocale); -} - -/** - * Auto-switches locale client-side to the logged in user's preference - */ -const I18nLanguageHandler = (props: { locales: string[] }) => { - const { locales } = props; - const { i18n } = useTranslation("common"); - const locale = useClientViewerI18n(locales).data?.locale || i18n.language; - - useEffect(() => { - // bail early when i18n = {} - if (Object.keys(i18n).length === 0) return; - // if locale is ready and the i18n.language does != locale - changeLanguage - if (locale && i18n.language !== locale) { - i18n.changeLanguage(locale); - } - // set dir="rtl|ltr" - document.dir = i18n.dir(); - document.documentElement.setAttribute("lang", locale); - }, [locale, i18n]); - return null; -}; - -export default I18nLanguageHandler; diff --git a/apps/web/components/PageWrapper.tsx b/apps/web/components/PageWrapper.tsx index 2ac8afca7c..5690b369bf 100644 --- a/apps/web/components/PageWrapper.tsx +++ b/apps/web/components/PageWrapper.tsx @@ -13,8 +13,6 @@ import type { AppProps } from "@lib/app-providers"; import AppProviders from "@lib/app-providers"; import { seoConfig } from "@lib/config/next-seo.config"; -import I18nLanguageHandler from "@components/I18nLanguageHandler"; - export interface CalPageWrapper { (props?: AppProps): JSX.Element; PageWrapper?: AppProps["Component"]["PageWrapper"]; @@ -72,7 +70,6 @@ function PageWrapper(props: AppProps) { } {...seoConfig.defaultNextSeo} /> - - ` - } + `} />

{t("need_help_embedding")}

@@ -118,7 +116,7 @@ export const tabs = [ { calLink: string; embedType: EmbedType; previewState: PreviewState } >(function Preview({ calLink, embedType }, ref) { const bookerUrl = useBookerUrl(); - const iframeSrc = `${EMBED_PREVIEW_HTML_URL}?embedType=${embedType}&calLink=${calLink}&embedLibUrl=${embedLibUrl}&bookerUrl=${bookerUrl}` + const iframeSrc = `${EMBED_PREVIEW_HTML_URL}?embedType=${embedType}&calLink=${calLink}&embedLibUrl=${embedLibUrl}&bookerUrl=${bookerUrl}`; if (ref instanceof Function || !ref) { return null; } diff --git a/packages/features/insights/server/events.ts b/packages/features/insights/server/events.ts index 44e9884579..b950dce3b9 100644 --- a/packages/features/insights/server/events.ts +++ b/packages/features/insights/server/events.ts @@ -440,8 +440,8 @@ class EventsInsights { if (!data.length) { return ""; } - const header = Object.keys(data[0]).join(",") + "\n"; - const rows = data.map((obj: any) => Object.values(obj).join(",") + "\n"); + const header = `${Object.keys(data[0]).join(",")}\n`; + const rows = data.map((obj: any) => `${Object.values(obj).join(",")}\n`); return header + rows.join(""); } } diff --git a/packages/features/webhooks/lib/scheduleTrigger.ts b/packages/features/webhooks/lib/scheduleTrigger.ts index 505a761c55..b2210dad6f 100644 --- a/packages/features/webhooks/lib/scheduleTrigger.ts +++ b/packages/features/webhooks/lib/scheduleTrigger.ts @@ -9,7 +9,7 @@ import prisma from "@calcom/prisma"; import type { ApiKey } from "@calcom/prisma/client"; import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; -const log = logger.getChildLogger({ prefix: ["[node-scheduler]"] }); +const log = logger.getSubLogger({ prefix: ["[node-scheduler]"] }); export async function addSubscription({ appApiKey, diff --git a/packages/lib/CalendarService.ts b/packages/lib/CalendarService.ts index 323ad01a64..5aeca7ee1b 100644 --- a/packages/lib/CalendarService.ts +++ b/packages/lib/CalendarService.ts @@ -121,7 +121,7 @@ export default abstract class BaseCalendarService implements Calendar { this.headers = getBasicAuthHeaders({ username, password }); this.credential = credential; - this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] }); + this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] }); } private getAttendees(event: CalendarEvent) { diff --git a/packages/lib/CloseCom.ts b/packages/lib/CloseCom.ts index 72c7fb71be..cb2587f953 100644 --- a/packages/lib/CloseCom.ts +++ b/packages/lib/CloseCom.ts @@ -193,7 +193,7 @@ export default class CloseCom { private log: typeof logger; constructor(providedApiKey = "") { - this.log = logger.getChildLogger({ prefix: [`[[lib] close.com`] }); + this.log = logger.getSubLogger({ prefix: [`[[lib] close.com`] }); if (!providedApiKey && !environmentApiKey) throw Error("Close.com Api Key not present"); this.apiKey = providedApiKey || environmentApiKey; } diff --git a/packages/lib/Sendgrid.ts b/packages/lib/Sendgrid.ts index 6677094fee..a67e2b4798 100644 --- a/packages/lib/Sendgrid.ts +++ b/packages/lib/Sendgrid.ts @@ -52,7 +52,7 @@ export default class Sendgrid { private log: typeof logger; constructor(providedApiKey = "") { - this.log = logger.getChildLogger({ prefix: [`[[lib] sendgrid`] }); + this.log = logger.getSubLogger({ prefix: [`[[lib] sendgrid`] }); if (!providedApiKey && !environmentApiKey) throw Error("Sendgrid Api Key not present"); client.setApiKey(providedApiKey || environmentApiKey); } diff --git a/packages/lib/logger.ts b/packages/lib/logger.ts index f3d5d4d296..4d6d4be3c6 100644 --- a/packages/lib/logger.ts +++ b/packages/lib/logger.ts @@ -3,19 +3,17 @@ import { Logger } from "tslog"; import { IS_PRODUCTION } from "./constants"; const logger = new Logger({ - minLevel: !!process.env.NEXT_PUBLIC_DEBUG ? "debug" : "warn", - dateTimePattern: "hour:minute:second.millisecond", - displayFunctionName: false, - displayFilePath: "hidden", - dateTimeTimezone: IS_PRODUCTION ? "utc" : Intl.DateTimeFormat().resolvedOptions().timeZone, - prettyInspectHighlightStyles: { - name: "yellow", - number: "blue", - bigint: "blue", - boolean: "blue", - }, + minLevel: !!process.env.NEXT_PUBLIC_DEBUG ? 2 : 4, maskValuesOfKeys: ["password", "passwordConfirmation", "credentials", "credential"], - exposeErrorCodeFrame: !IS_PRODUCTION, + prettyLogTimeZone: IS_PRODUCTION ? "UTC" : "local", + prettyErrorStackTemplate: " • {{fileName}}\t{{method}}\n\t{{filePathWithLine}}", // default + prettyErrorTemplate: "\n{{errorName}} {{errorMessage}}\nerror stack:\n{{errorStack}}", // default + prettyLogTemplate: "{{hh}}:{{MM}}:{{ss}}:{{ms}}\t{{logLevelName}}", // default with exclusion of `{{filePathWithLine}}` + stylePrettyLogs: true, + prettyLogStyles: { + name: "yellow", + dateIsoStr: "blue", + }, }); export default logger; diff --git a/packages/lib/package.json b/packages/lib/package.json index 1683a06975..b2ada1df57 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -27,7 +27,7 @@ "rrule": "^2.7.1", "tailwind-merge": "^1.13.2", "tsdav": "2.0.3", - "tslog": "^3.2.1", + "tslog": "^4.9.2", "uuid": "^8.3.2" }, "devDependencies": { diff --git a/packages/lib/payment/handlePaymentSuccess.ts b/packages/lib/payment/handlePaymentSuccess.ts index 4e0a88867a..fa477d5ebc 100644 --- a/packages/lib/payment/handlePaymentSuccess.ts +++ b/packages/lib/payment/handlePaymentSuccess.ts @@ -12,7 +12,7 @@ import { BookingStatus } from "@calcom/prisma/enums"; import logger from "../logger"; -const log = logger.getChildLogger({ prefix: ["[handlePaymentSuccess]"] }); +const log = logger.getSubLogger({ prefix: ["[handlePaymentSuccess]"] }); export async function handlePaymentSuccess(paymentId: number, bookingId: number) { const { booking, user: userWithCredentials, evt, eventType } = await getBooking(bookingId); diff --git a/packages/lib/rateLimit.ts b/packages/lib/rateLimit.ts index 876efb79aa..be2c74308d 100644 --- a/packages/lib/rateLimit.ts +++ b/packages/lib/rateLimit.ts @@ -4,7 +4,7 @@ import { Redis } from "@upstash/redis"; import { isIpInBanListString } from "./getIP"; import logger from "./logger"; -const log = logger.getChildLogger({ prefix: ["RateLimit"] }); +const log = logger.getSubLogger({ prefix: ["RateLimit"] }); export type RateLimitHelper = { rateLimitingType?: "core" | "forcedSlowMode" | "common" | "api" | "ai"; diff --git a/packages/lib/redactError.ts b/packages/lib/redactError.ts index 820e00e99f..de9fd404b3 100644 --- a/packages/lib/redactError.ts +++ b/packages/lib/redactError.ts @@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client"; import logger from "@calcom/lib/logger"; -const log = logger.getChildLogger({ prefix: [`[[redactError]`] }); +const log = logger.getSubLogger({ prefix: [`[[redactError]`] }); function shouldRedact(error: T) { return ( diff --git a/packages/lib/sync/SyncServiceManager.ts b/packages/lib/sync/SyncServiceManager.ts index 95cbd14b64..621c6e14aa 100644 --- a/packages/lib/sync/SyncServiceManager.ts +++ b/packages/lib/sync/SyncServiceManager.ts @@ -6,7 +6,7 @@ import type { ConsoleUserInfoType, TeamInfoType, WebUserInfoType } from "./ISync import services from "./services"; import CloseComService from "./services/CloseComService"; -const log = logger.getChildLogger({ prefix: [`[[SyncServiceManager] `] }); +const log = logger.getSubLogger({ prefix: [`[[SyncServiceManager] `] }); export const createConsoleUser = async (user: ConsoleUserInfoType | null | undefined) => { if (user) { diff --git a/packages/lib/sync/services/CloseComService.ts b/packages/lib/sync/services/CloseComService.ts index a7243afb84..9be83a643a 100644 --- a/packages/lib/sync/services/CloseComService.ts +++ b/packages/lib/sync/services/CloseComService.ts @@ -25,7 +25,7 @@ export default class CloseComService extends SyncServiceCore implements ISyncSer protected declare service: CloseCom; constructor() { - super(serviceName, CloseCom, logger.getChildLogger({ prefix: [`[[sync] ${serviceName}`] })); + super(serviceName, CloseCom, logger.getSubLogger({ prefix: [`[[sync] ${serviceName}`] })); } upsertAnyUser = async ( diff --git a/packages/lib/sync/services/SendgridService.ts b/packages/lib/sync/services/SendgridService.ts index 8d4762d357..3c081e9fb8 100644 --- a/packages/lib/sync/services/SendgridService.ts +++ b/packages/lib/sync/services/SendgridService.ts @@ -20,7 +20,7 @@ const serviceName = "sendgrid_service"; export default class SendgridService extends SyncServiceCore implements ISyncService { protected declare service: Sendgrid; constructor() { - super(serviceName, Sendgrid, logger.getChildLogger({ prefix: [`[[sync] ${serviceName}`] })); + super(serviceName, Sendgrid, logger.getSubLogger({ prefix: [`[[sync] ${serviceName}`] })); } upsert = async (user: WebUserInfoType | ConsoleUserInfoType) => { diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 75373b7394..78c0ce069c 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -978,20 +978,21 @@ model CalendarCache { enum RedirectType { UserEventType @map("user-event-type") TeamEventType @map("team-event-type") - User @map("user") - Team @map("team") + User @map("user") + Team @map("team") } model TempOrgRedirect { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) // Better would be to have fromOrgId and toOrgId as well and then we should have just to instead toUrl from String // 0 would mean it is non org - fromOrgId Int + fromOrgId Int type RedirectType toUrl String - enabled Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@unique([from, type, fromOrgId]) -} \ No newline at end of file +} diff --git a/packages/trpc/server/routers/viewer/auth/resendVerifyEmail.handler.ts b/packages/trpc/server/routers/viewer/auth/resendVerifyEmail.handler.ts index b5a4a77c4a..89659b6b1e 100644 --- a/packages/trpc/server/routers/viewer/auth/resendVerifyEmail.handler.ts +++ b/packages/trpc/server/routers/viewer/auth/resendVerifyEmail.handler.ts @@ -9,7 +9,7 @@ type ResendEmailOptions = { }; }; -const log = logger.getChildLogger({ prefix: [`[[Auth] `] }); +const log = logger.getSubLogger({ prefix: [`[[Auth] `] }); export const resendVerifyEmail = async ({ ctx }: ResendEmailOptions) => { if (ctx.user.emailVerified) { diff --git a/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts b/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts index 6ae1ccdaba..876d95eb58 100644 --- a/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts +++ b/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts @@ -1,6 +1,6 @@ -import { prisma } from "@calcom/prisma"; import { getBusyCalendarTimes } from "@calcom/core/CalendarManager"; import dayjs from "@calcom/dayjs"; +import { prisma } from "@calcom/prisma"; import type { EventBusyDate } from "@calcom/types/Calendar"; import { TRPCError } from "@trpc/server"; diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 6b9cc9abfe..11f582757d 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -268,7 +268,7 @@ export function getRegularOrDynamicEventType( export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) { const orgDetails = orgDomainConfig(ctx?.req?.headers.host ?? ""); if (process.env.INTEGRATION_TEST_MODE === "true") { - logger.setSettings({ minLevel: "silly" }); + logger.settings.minLevel = 2; } const startPrismaEventTypeGet = performance.now(); const eventType = await getRegularOrDynamicEventType(input, orgDetails); @@ -279,10 +279,10 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) { } if (isEventTypeLoggingEnabled({ eventTypeId: eventType.id })) { - logger.setSettings({ minLevel: "debug" }); + logger.settings.minLevel = 2; } - const loggerWithEventDetails = logger.getChildLogger({ + const loggerWithEventDetails = logger.getSubLogger({ prefix: ["getAvailableSlots", `${eventType.id}:${input.usernameList}/${input.eventTypeSlug}`], }); diff --git a/yarn.lock b/yarn.lock index acad17e7a2..16eb04a285 100644 --- a/yarn.lock +++ b/yarn.lock @@ -91,6 +91,13 @@ __metadata: languageName: node linkType: hard +"@alloc/quick-lru@npm:^5.2.0": + version: 5.2.0 + resolution: "@alloc/quick-lru@npm:5.2.0" + checksum: bdc35758b552bcf045733ac047fb7f9a07c4678b944c641adfbd41f798b4b91fffd0fdc0df2578d9b0afc7b4d636aa6e110ead5d6281a2adc1ab90efd7f057f8 + languageName: node + linkType: hard + "@ampproject/remapping@npm:^2.2.0": version: 2.2.1 resolution: "@ampproject/remapping@npm:2.2.1" @@ -3381,7 +3388,7 @@ __metadata: "@types/mailparser": ^3.4.0 langchain: ^0.0.131 mailparser: ^3.6.5 - next: ^13.4.6 + next: ^13.5.4 supports-color: 8.1.1 zod: ^3.22.2 languageName: unknown @@ -3538,7 +3545,7 @@ __metadata: "@types/react-dom": ^18.0.9 eslint: ^8.34.0 eslint-config-next: ^13.2.1 - next: ^13.4.6 + next: ^13.5.4 next-auth: ^4.22.1 postcss: ^8.4.18 react: ^18.2.0 @@ -3637,7 +3644,7 @@ __metadata: "@calcom/ui": "*" "@headlessui/react": ^1.5.0 "@heroicons/react": ^1.0.6 - "@prisma/client": ^5.4.2 + "@prisma/client": ^5.3.0 "@tailwindcss/forms": ^0.5.2 "@types/node": 16.9.1 "@types/react": 18.0.26 @@ -4041,7 +4048,7 @@ __metadata: rrule: ^2.7.1 tailwind-merge: ^1.13.2 tsdav: 2.0.3 - tslog: ^3.2.1 + tslog: ^4.9.2 typescript: ^4.9.4 uuid: ^8.3.2 languageName: unknown @@ -4644,6 +4651,7 @@ __metadata: "@calcom/ui": "*" "@datocms/cma-client-node": ^2.0.0 "@floating-ui/react-dom": ^1.0.0 + "@flodlc/nebula": ^1.0.56 "@graphql-codegen/cli": ^5.0.0 "@graphql-codegen/typed-document-node": ^5.0.1 "@graphql-codegen/typescript": ^4.0.1 @@ -4655,6 +4663,7 @@ __metadata: "@juggle/resize-observer": ^3.4.0 "@next/bundle-analyzer": ^13.1.6 "@radix-ui/react-accordion": ^1.0.0 + "@radix-ui/react-avatar": ^1.0.4 "@radix-ui/react-dropdown-menu": ^2.0.5 "@radix-ui/react-navigation-menu": ^1.0.0 "@radix-ui/react-portal": ^1.0.0 @@ -4718,6 +4727,7 @@ __metadata: react-hot-toast: ^2.3.0 react-live-chat-loader: ^2.8.1 react-merge-refs: 1.1.0 + react-resize-detector: ^9.1.0 react-twemoji: ^0.3.0 react-use-measure: ^2.1.1 react-wrap-balancer: ^1.0.0 @@ -4726,7 +4736,7 @@ __metadata: remeda: ^1.24.1 stripe: ^9.16.0 tailwind-merge: ^1.13.2 - tailwindcss: ^3.3.1 + tailwindcss: ^3.3.3 ts-node: ^10.9.1 typescript: ^4.9.4 wait-on: ^7.0.1 @@ -5936,6 +5946,13 @@ __metadata: languageName: node linkType: hard +"@flodlc/nebula@npm:^1.0.56": + version: 1.0.56 + resolution: "@flodlc/nebula@npm:1.0.56" + checksum: 044058423bc8a2c6ea60a0636400593a0912c142fbb6f50cc03288c702ae9c2029f84eb4fbac7e701a7ee1c2a5e33cc1af1b8d94af419c1197f74066b7867d21 + languageName: node + linkType: hard + "@formatjs/ecma402-abstract@npm:1.11.4": version: 1.11.4 resolution: "@formatjs/ecma402-abstract@npm:1.11.4" @@ -7779,6 +7796,13 @@ __metadata: languageName: node linkType: hard +"@next/env@npm:13.5.5": + version: 13.5.5 + resolution: "@next/env@npm:13.5.5" + checksum: 4e3a92f2bd60189d81eb0437bf8141de26dec371edc125553c2d93b1de4c40ce99e8c81f60d8450961fab5c8880a6bcfccd23d9ef9c86aceab2f5380776def9f + languageName: node + linkType: hard + "@next/eslint-plugin-next@npm:13.2.1": version: 13.2.1 resolution: "@next/eslint-plugin-next@npm:13.2.1" @@ -7795,6 +7819,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-darwin-arm64@npm:13.5.5": + version: 13.5.5 + resolution: "@next/swc-darwin-arm64@npm:13.5.5" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@next/swc-darwin-x64@npm:13.4.6": version: 13.4.6 resolution: "@next/swc-darwin-x64@npm:13.4.6" @@ -7802,6 +7833,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-darwin-x64@npm:13.5.5": + version: 13.5.5 + resolution: "@next/swc-darwin-x64@npm:13.5.5" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@next/swc-linux-arm64-gnu@npm:13.4.6": version: 13.4.6 resolution: "@next/swc-linux-arm64-gnu@npm:13.4.6" @@ -7809,6 +7847,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-arm64-gnu@npm:13.5.5": + version: 13.5.5 + resolution: "@next/swc-linux-arm64-gnu@npm:13.5.5" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@next/swc-linux-arm64-musl@npm:13.4.6": version: 13.4.6 resolution: "@next/swc-linux-arm64-musl@npm:13.4.6" @@ -7816,6 +7861,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-arm64-musl@npm:13.5.5": + version: 13.5.5 + resolution: "@next/swc-linux-arm64-musl@npm:13.5.5" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@next/swc-linux-x64-gnu@npm:13.4.6": version: 13.4.6 resolution: "@next/swc-linux-x64-gnu@npm:13.4.6" @@ -7823,6 +7875,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-x64-gnu@npm:13.5.5": + version: 13.5.5 + resolution: "@next/swc-linux-x64-gnu@npm:13.5.5" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@next/swc-linux-x64-musl@npm:13.4.6": version: 13.4.6 resolution: "@next/swc-linux-x64-musl@npm:13.4.6" @@ -7830,6 +7889,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-x64-musl@npm:13.5.5": + version: 13.5.5 + resolution: "@next/swc-linux-x64-musl@npm:13.5.5" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@next/swc-win32-arm64-msvc@npm:13.4.6": version: 13.4.6 resolution: "@next/swc-win32-arm64-msvc@npm:13.4.6" @@ -7837,6 +7903,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-win32-arm64-msvc@npm:13.5.5": + version: 13.5.5 + resolution: "@next/swc-win32-arm64-msvc@npm:13.5.5" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@next/swc-win32-ia32-msvc@npm:13.4.6": version: 13.4.6 resolution: "@next/swc-win32-ia32-msvc@npm:13.4.6" @@ -7844,6 +7917,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-win32-ia32-msvc@npm:13.5.5": + version: 13.5.5 + resolution: "@next/swc-win32-ia32-msvc@npm:13.5.5" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@next/swc-win32-x64-msvc@npm:13.4.6": version: 13.4.6 resolution: "@next/swc-win32-x64-msvc@npm:13.4.6" @@ -7851,6 +7931,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-win32-x64-msvc@npm:13.5.5": + version: 13.5.5 + resolution: "@next/swc-win32-x64-msvc@npm:13.5.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@noble/curves@npm:1.1.0, @noble/curves@npm:~1.1.0": version: 1.1.0 resolution: "@noble/curves@npm:1.1.0" @@ -8158,7 +8245,7 @@ __metadata: languageName: node linkType: hard -"@prisma/client@npm:^5.4.2": +"@prisma/client@npm:^5.3.0, @prisma/client@npm:^5.4.2": version: 5.4.2 resolution: "@prisma/client@npm:5.4.2" dependencies: @@ -8510,6 +8597,29 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-avatar@npm:^1.0.4": + version: 1.0.4 + resolution: "@radix-ui/react-avatar@npm:1.0.4" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-context": 1.0.1 + "@radix-ui/react-primitive": 1.0.3 + "@radix-ui/react-use-callback-ref": 1.0.1 + "@radix-ui/react-use-layout-effect": 1.0.1 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 63b9c3d1637dea4bac74cb8f1b7825cb28921778e5e21365fe2e9569a1e4ee434a43b072789ce4a71af878b067c48bdb549d7eb8c193ed5750b8cf17cfbc418e + languageName: node + linkType: hard + "@radix-ui/react-checkbox@npm:^1.0.4": version: 1.0.4 resolution: "@radix-ui/react-checkbox@npm:1.0.4" @@ -12109,6 +12219,15 @@ __metadata: languageName: node linkType: hard +"@swc/helpers@npm:0.5.2": + version: 0.5.2 + resolution: "@swc/helpers@npm:0.5.2" + dependencies: + tslib: ^2.4.0 + checksum: 51d7e3d8bd56818c49d6bfbd715f0dbeedc13cf723af41166e45c03e37f109336bbcb57a1f2020f4015957721aeb21e1a7fff281233d797ff7d3dd1f447fa258 + languageName: node + linkType: hard + "@szmarczak/http-timer@npm:^4.0.5": version: 4.0.6 resolution: "@szmarczak/http-timer@npm:4.0.6" @@ -24658,7 +24777,7 @@ __metadata: languageName: node linkType: hard -"is-core-module@npm:^2.11.0": +"is-core-module@npm:^2.11.0, is-core-module@npm:^2.13.0": version: 2.13.0 resolution: "is-core-module@npm:2.13.0" dependencies: @@ -26793,6 +26912,13 @@ __metadata: languageName: node linkType: hard +"lilconfig@npm:^2.1.0": + version: 2.1.0 + resolution: "lilconfig@npm:2.1.0" + checksum: 8549bb352b8192375fed4a74694cd61ad293904eee33f9d4866c2192865c44c4eb35d10782966242634e0cbc1e91fe62b1247f148dc5514918e3a966da7ea117 + languageName: node + linkType: hard + "limiter@npm:^1.1.5": version: 1.1.5 resolution: "limiter@npm:1.1.5" @@ -29426,6 +29552,61 @@ __metadata: languageName: node linkType: hard +"next@npm:^13.5.4": + version: 13.5.5 + resolution: "next@npm:13.5.5" + dependencies: + "@next/env": 13.5.5 + "@next/swc-darwin-arm64": 13.5.5 + "@next/swc-darwin-x64": 13.5.5 + "@next/swc-linux-arm64-gnu": 13.5.5 + "@next/swc-linux-arm64-musl": 13.5.5 + "@next/swc-linux-x64-gnu": 13.5.5 + "@next/swc-linux-x64-musl": 13.5.5 + "@next/swc-win32-arm64-msvc": 13.5.5 + "@next/swc-win32-ia32-msvc": 13.5.5 + "@next/swc-win32-x64-msvc": 13.5.5 + "@swc/helpers": 0.5.2 + busboy: 1.6.0 + caniuse-lite: ^1.0.30001406 + postcss: 8.4.31 + styled-jsx: 5.1.1 + watchpack: 2.4.0 + peerDependencies: + "@opentelemetry/api": ^1.1.0 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + dependenciesMeta: + "@next/swc-darwin-arm64": + optional: true + "@next/swc-darwin-x64": + optional: true + "@next/swc-linux-arm64-gnu": + optional: true + "@next/swc-linux-arm64-musl": + optional: true + "@next/swc-linux-x64-gnu": + optional: true + "@next/swc-linux-x64-musl": + optional: true + "@next/swc-win32-arm64-msvc": + optional: true + "@next/swc-win32-ia32-msvc": + optional: true + "@next/swc-win32-x64-msvc": + optional: true + peerDependenciesMeta: + "@opentelemetry/api": + optional: true + sass: + optional: true + bin: + next: dist/bin/next + checksum: 034a52cf9a5df79912ad67467e00ab98e6505a7544514a12d6310d67fea760764f6b04ade344d457aadecb6170dd50eb0709346fd97a9e6659fcabd5e510fb97 + languageName: node + linkType: hard + "nice-try@npm:^1.0.4": version: 1.0.5 resolution: "nice-try@npm:1.0.5" @@ -31395,6 +31576,19 @@ __metadata: languageName: node linkType: hard +"postcss-import@npm:^15.1.0": + version: 15.1.0 + resolution: "postcss-import@npm:15.1.0" + dependencies: + postcss-value-parser: ^4.0.0 + read-cache: ^1.0.0 + resolve: ^1.1.7 + peerDependencies: + postcss: ^8.0.0 + checksum: 7bd04bd8f0235429009d0022cbf00faebc885de1d017f6d12ccb1b021265882efc9302006ba700af6cab24c46bfa2f3bc590be3f9aee89d064944f171b04e2a3 + languageName: node + linkType: hard + "postcss-js@npm:^4.0.0": version: 4.0.0 resolution: "postcss-js@npm:4.0.0" @@ -31406,6 +31600,17 @@ __metadata: languageName: node linkType: hard +"postcss-js@npm:^4.0.1": + version: 4.0.1 + resolution: "postcss-js@npm:4.0.1" + dependencies: + camelcase-css: ^2.0.1 + peerDependencies: + postcss: ^8.4.21 + checksum: 5c1e83efeabeb5a42676193f4357aa9c88f4dc1b3c4a0332c132fe88932b33ea58848186db117cf473049fc233a980356f67db490bd0a7832ccba9d0b3fd3491 + languageName: node + linkType: hard + "postcss-load-config@npm:^3.1.4": version: 3.1.4 resolution: "postcss-load-config@npm:3.1.4" @@ -31424,6 +31629,24 @@ __metadata: languageName: node linkType: hard +"postcss-load-config@npm:^4.0.1": + version: 4.0.1 + resolution: "postcss-load-config@npm:4.0.1" + dependencies: + lilconfig: ^2.0.5 + yaml: ^2.1.1 + peerDependencies: + postcss: ">=8.0.9" + ts-node: ">=9.0.0" + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + checksum: b61f890499ed7dcda1e36c20a9582b17d745bad5e2b2c7bc96942465e406bc43ae03f270c08e60d1e29dab1ee50cb26970b5eb20c9aae30e066e20bd607ae4e4 + languageName: node + linkType: hard + "postcss-loader@npm:^4.2.0": version: 4.3.0 resolution: "postcss-loader@npm:4.3.0" @@ -31550,6 +31773,17 @@ __metadata: languageName: node linkType: hard +"postcss-nested@npm:^6.0.1": + version: 6.0.1 + resolution: "postcss-nested@npm:6.0.1" + dependencies: + postcss-selector-parser: ^6.0.11 + peerDependencies: + postcss: ^8.2.14 + checksum: 7ddb0364cd797de01e38f644879189e0caeb7ea3f78628c933d91cc24f327c56d31269384454fc02ecaf503b44bfa8e08870a7c4cc56b23bc15640e1894523fa + languageName: node + linkType: hard + "postcss-pseudo-companion-classes@npm:^0.1.1": version: 0.1.1 resolution: "postcss-pseudo-companion-classes@npm:0.1.1" @@ -31604,6 +31838,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:8.4.31": + version: 8.4.31 + resolution: "postcss@npm:8.4.31" + dependencies: + nanoid: ^3.3.6 + picocolors: ^1.0.0 + source-map-js: ^1.0.2 + checksum: 1d8611341b073143ad90486fcdfeab49edd243377b1f51834dc4f6d028e82ce5190e4f11bb2633276864503654fb7cab28e67abdc0fbf9d1f88cad4a0ff0beea + languageName: node + linkType: hard + "postcss@npm:^7.0.14, postcss@npm:^7.0.26, postcss@npm:^7.0.32, postcss@npm:^7.0.36, postcss@npm:^7.0.5, postcss@npm:^7.0.6": version: 7.0.39 resolution: "postcss@npm:7.0.39" @@ -33177,6 +33422,18 @@ __metadata: languageName: node linkType: hard +"react-resize-detector@npm:^9.1.0": + version: 9.1.0 + resolution: "react-resize-detector@npm:9.1.0" + dependencies: + lodash: ^4.17.21 + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: 05b263e141fd428eea433e399f88c3e1a379b4a2293958f59b5a5c75dd86c621ce60583724257cc3dc1f5c120a664666ff3fa53d41e6c283687676dc55afa02b + languageName: node + linkType: hard + "react-schemaorg@npm:^2.0.0": version: 2.0.0 resolution: "react-schemaorg@npm:2.0.0" @@ -34205,6 +34462,19 @@ __metadata: languageName: node linkType: hard +"resolve@npm:^1.22.2": + version: 1.22.8 + resolution: "resolve@npm:1.22.8" + dependencies: + is-core-module: ^2.13.0 + path-parse: ^1.0.7 + supports-preserve-symlinks-flag: ^1.0.0 + bin: + resolve: bin/resolve + checksum: f8a26958aa572c9b064562750b52131a37c29d072478ea32e129063e2da7f83e31f7f11e7087a18225a8561cfe8d2f0df9dbea7c9d331a897571c0a2527dbb4c + languageName: node + linkType: hard + "resolve@npm:^2.0.0-next.3": version: 2.0.0-next.3 resolution: "resolve@npm:2.0.0-next.3" @@ -34254,6 +34524,19 @@ __metadata: languageName: node linkType: hard +"resolve@patch:resolve@^1.22.2#~builtin": + version: 1.22.8 + resolution: "resolve@patch:resolve@npm%3A1.22.8#~builtin::version=1.22.8&hash=c3c19d" + dependencies: + is-core-module: ^2.13.0 + path-parse: ^1.0.7 + supports-preserve-symlinks-flag: ^1.0.0 + bin: + resolve: bin/resolve + checksum: 5479b7d431cacd5185f8db64bfcb7286ae5e31eb299f4c4f404ad8aa6098b77599563ac4257cb2c37a42f59dfc06a1bec2bcf283bb448f319e37f0feb9a09847 + languageName: node + linkType: hard + "resolve@patch:resolve@^2.0.0-next.3#~builtin": version: 2.0.0-next.3 resolution: "resolve@patch:resolve@npm%3A2.0.0-next.3#~builtin::version=2.0.0-next.3&hash=c3c19d" @@ -35439,7 +35722,7 @@ __metadata: languageName: node linkType: hard -"source-map-support@npm:^0.5.16, source-map-support@npm:^0.5.21, source-map-support@npm:~0.5.12, source-map-support@npm:~0.5.20": +"source-map-support@npm:^0.5.16, source-map-support@npm:~0.5.12, source-map-support@npm:~0.5.20": version: 0.5.21 resolution: "source-map-support@npm:0.5.21" dependencies: @@ -36385,6 +36668,24 @@ __metadata: languageName: node linkType: hard +"sucrase@npm:^3.32.0": + version: 3.34.0 + resolution: "sucrase@npm:3.34.0" + dependencies: + "@jridgewell/gen-mapping": ^0.3.2 + commander: ^4.0.0 + glob: 7.1.6 + lines-and-columns: ^1.1.6 + mz: ^2.7.0 + pirates: ^4.0.1 + ts-interface-checker: ^0.1.9 + bin: + sucrase: bin/sucrase + sucrase-node: bin/sucrase-node + checksum: 61860063bdf6103413698e13247a3074d25843e91170825a9752e4af7668ffadd331b6e99e92fc32ee5b3c484ee134936f926fa9039d5711fafff29d017a2110 + languageName: node + linkType: hard + "superagent@npm:^5.1.1": version: 5.3.1 resolution: "superagent@npm:5.3.1" @@ -36783,6 +37084,39 @@ __metadata: languageName: node linkType: hard +"tailwindcss@npm:^3.3.3": + version: 3.3.3 + resolution: "tailwindcss@npm:3.3.3" + dependencies: + "@alloc/quick-lru": ^5.2.0 + arg: ^5.0.2 + chokidar: ^3.5.3 + didyoumean: ^1.2.2 + dlv: ^1.1.3 + fast-glob: ^3.2.12 + glob-parent: ^6.0.2 + is-glob: ^4.0.3 + jiti: ^1.18.2 + lilconfig: ^2.1.0 + micromatch: ^4.0.5 + normalize-path: ^3.0.0 + object-hash: ^3.0.0 + picocolors: ^1.0.0 + postcss: ^8.4.23 + postcss-import: ^15.1.0 + postcss-js: ^4.0.1 + postcss-load-config: ^4.0.1 + postcss-nested: ^6.0.1 + postcss-selector-parser: ^6.0.11 + resolve: ^1.22.2 + sucrase: ^3.32.0 + bin: + tailwind: lib/cli.js + tailwindcss: lib/cli.js + checksum: 0195c7a3ebb0de5e391d2a883d777c78a4749f0c532d204ee8aea9129f2ed8e701d8c0c276aa5f7338d07176a3c2a7682c1d0ab9c8a6c2abe6d9325c2954eb50 + languageName: node + linkType: hard + "tapable@npm:^1.0.0, tapable@npm:^1.1.3": version: 1.1.3 resolution: "tapable@npm:1.1.3" @@ -37708,12 +38042,10 @@ __metadata: languageName: node linkType: hard -"tslog@npm:^3.2.1": - version: 3.3.3 - resolution: "tslog@npm:3.3.3" - dependencies: - source-map-support: ^0.5.21 - checksum: ae84f4056865ad2d5a1f33d491387e4fd6c24642a08ccc29b8fcebce20784e94ef8b5863df5a5f85ec881125abc2b6b8b3aa022d2401a3716643332158346720 +"tslog@npm:^4.9.2": + version: 4.9.2 + resolution: "tslog@npm:4.9.2" + checksum: 702e45647a68b127d63c5eb63a0f322af8d01f17b689127d32238d6ca2ef76889648a00b88c040430e3126acedef070022b20ebd81823879ba3766cf5188c868 languageName: node linkType: hard @@ -40641,6 +40973,13 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.1.1": + version: 2.3.3 + resolution: "yaml@npm:2.3.3" + checksum: cdfd132e7e0259f948929efe8835923df05c013c273c02bb7a2de9b46ac3af53c2778a35b32c7c0f877cc355dc9340ed564018c0242bfbb1278c2a3e53a0e99e + languageName: node + linkType: hard + "yaml@npm:^2.2.1": version: 2.3.1 resolution: "yaml@npm:2.3.1" From 59fa713549e38aa92161499e171b74cceb9f578b Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Tue, 17 Oct 2023 20:56:46 +0100 Subject: [PATCH 106/120] v3.4.1 --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index ab5d5396e1..6ba03c5b9b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@calcom/web", - "version": "3.4.0", + "version": "3.4.1", "private": true, "scripts": { "analyze": "ANALYZE=true next build", From 0c92fbe11dc370ae0b4a42ac5b4ba513bd3d9e05 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Wed, 18 Oct 2023 11:47:39 +0200 Subject: [PATCH 107/120] fix: failing scheduleEmailReminder cron job (#11960) Co-authored-by: CarinaWolli --- .../workflows/api/scheduleEmailReminders.ts | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/features/ee/workflows/api/scheduleEmailReminders.ts b/packages/features/ee/workflows/api/scheduleEmailReminders.ts index 06aa08c447..3ce801fb0f 100644 --- a/packages/features/ee/workflows/api/scheduleEmailReminders.ts +++ b/packages/features/ee/workflows/api/scheduleEmailReminders.ts @@ -34,7 +34,11 @@ type Booking = Prisma.BookingGetPayload<{ }; }>; -function getiCalEventAsString(booking: Booking) { +function getiCalEventAsString( + booking: Pick & { + eventType: { recurringEvent?: Prisma.JsonValue; title?: string } | null; + } +) { let recurrenceRule: string | undefined = undefined; const recurringEvent = parseRecurringEvent(booking.eventType?.recurringEvent); if (recurringEvent?.count) { @@ -114,6 +118,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { }, skip: pageNumber * pageSize, take: pageSize, + select: { + referenceId: true, + }, }); if (remindersToDelete.length === 0) { @@ -156,6 +163,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { }, skip: pageNumber * pageSize, take: pageSize, + select: { + referenceId: true, + id: true, + }, }); if (remindersToCancel.length === 0) { @@ -203,13 +214,40 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { }, skip: pageNumber * pageSize, take: pageSize, - include: { - workflowStep: true, + select: { + id: true, + scheduledDate: true, + workflowStep: { + select: { + action: true, + sendTo: true, + reminderBody: true, + emailSubject: true, + template: true, + sender: true, + includeCalendarEvent: true, + }, + }, booking: { - include: { - eventType: true, + select: { + startTime: true, + endTime: true, + location: true, + description: true, user: true, + metadata: true, + uid: true, + customInputs: true, + responses: true, attendees: true, + eventType: { + select: { + bookingFields: true, + title: true, + slug: true, + recurringEvent: true, + }, + }, }, }, }, From 0b46f61a239b4b4db452b6980c482628ba1fff97 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Wed, 18 Oct 2023 11:16:02 +0100 Subject: [PATCH 108/120] feat: (Overlay) Persist toggle option (#11961) --- .../Booker/components/LargeCalendar.tsx | 3 +- .../OverlayCalendarContainer.tsx | 174 ++++++++++-------- .../bookings/components/AvailableTimes.tsx | 3 +- 3 files changed, 105 insertions(+), 75 deletions(-) diff --git a/packages/features/bookings/Booker/components/LargeCalendar.tsx b/packages/features/bookings/Booker/components/LargeCalendar.tsx index 86fd16996a..f71dfe113e 100644 --- a/packages/features/bookings/Booker/components/LargeCalendar.tsx +++ b/packages/features/bookings/Booker/components/LargeCalendar.tsx @@ -19,7 +19,8 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => { const schedule = useScheduleForEvent({ prefetchNextMonth: !!extraDays && dayjs(date).month() !== dayjs(date).add(extraDays, "day").month(), }); - const displayOverlay = getQueryParam("overlayCalendar") === "true"; + const displayOverlay = + getQueryParam("overlayCalendar") === "true" || localStorage.getItem("overlayCalendarSwitchDefault"); const event = useEvent(); const eventDuration = selectedEventDuration || event?.data?.length || 30; diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx index a44543f67c..f0a521cea0 100644 --- a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx +++ b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx @@ -1,5 +1,5 @@ import { useSession } from "next-auth/react"; -import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { useSearchParams, useRouter, usePathname } from "next/navigation"; import { useState, useCallback, useEffect } from "react"; import dayjs from "@calcom/dayjs"; @@ -17,19 +17,108 @@ import { OverlayCalendarSettingsModal } from "../OverlayCalendar/OverlayCalendar import { useLocalSet } from "../hooks/useLocalSet"; import { useOverlayCalendarStore } from "./store"; -export function OverlayCalendarContainer() { +interface OverlayCalendarSwitchProps { + setContinueWithProvider: (val: boolean) => void; + setCalendarSettingsOverlay: (val: boolean) => void; + enabled?: boolean; +} + +function OverlayCalendarSwitch({ + setCalendarSettingsOverlay, + setContinueWithProvider, + enabled, +}: OverlayCalendarSwitchProps) { const { t } = useLocale(); + const layout = useBookerStore((state) => state.layout); + const searchParams = useSearchParams(); + const { data: session } = useSession(); + const router = useRouter(); + const pathname = usePathname(); + const switchEnabled = enabled; + + // Toggle query param for overlay calendar + const toggleOverlayCalendarQueryParam = useCallback( + (state: boolean) => { + const current = new URLSearchParams(Array.from(searchParams.entries())); + if (state) { + current.set("overlayCalendar", "true"); + localStorage.setItem("overlayCalendarSwitchDefault", "true"); + } else { + current.delete("overlayCalendar"); + localStorage.removeItem("overlayCalendarSwitchDefault"); + } + // cast to string + const value = current.toString(); + const query = value ? `?${value}` : ""; + router.push(`${pathname}${query}`); + }, + [searchParams, pathname, router] + ); + + /** + * If a user is not logged in and the overlay calendar query param is true, + * show the continue modal so they can login / create an account + */ + useEffect(() => { + if (!session && switchEnabled) { + toggleOverlayCalendarQueryParam(false); + setContinueWithProvider(true); + } + }, [session, switchEnabled, setContinueWithProvider, toggleOverlayCalendarQueryParam]); + + return ( +
+
+ { + if (!session) { + setContinueWithProvider(state); + } else { + toggleOverlayCalendarQueryParam(state); + } + }} + /> + +
+ {session && ( +
+ ); +} + +export function OverlayCalendarContainer() { const isEmbed = useIsEmbed(); + const searchParams = useSearchParams(); const [continueWithProvider, setContinueWithProvider] = useState(false); const [calendarSettingsOverlay, setCalendarSettingsOverlay] = useState(false); const { data: session } = useSession(); const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates); + const switchEnabled = + searchParams.get("overlayCalendar") === "true" || + localStorage.getItem("overlayCalendarSwitchDefault") === "true"; - const layout = useBookerStore((state) => state.layout); const selectedDate = useBookerStore((state) => state.selectedDate); - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); const { timezone } = useTimePreferences(); // Move this to a hook @@ -37,7 +126,6 @@ export function OverlayCalendarContainer() { credentialId: number; externalId: string; }>("toggledConnectedCalendars", []); - const overlayCalendarQueryParam = searchParams.get("overlayCalendar"); const { data: overlayBusyDates } = trpc.viewer.availability.calendarOverlay.useQuery( { @@ -50,7 +138,7 @@ export function OverlayCalendarContainer() { })), }, { - enabled: !!session && set.size > 0 && overlayCalendarQueryParam === "true", + enabled: !!session && set.size > 0 && switchEnabled, onError: () => { clearSet(); }, @@ -76,77 +164,17 @@ export function OverlayCalendarContainer() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [overlayBusyDates]); - // Toggle query param for overlay calendar - const toggleOverlayCalendarQueryParam = useCallback( - (state: boolean) => { - const current = new URLSearchParams(Array.from(searchParams.entries())); - if (state) { - current.set("overlayCalendar", "true"); - } else { - current.delete("overlayCalendar"); - } - // cast to string - const value = current.toString(); - const query = value ? `?${value}` : ""; - router.push(`${pathname}${query}`); - }, - [searchParams, pathname, router] - ); - - /** - * If a user is not logged in and the overlay calendar query param is true, - * show the continue modal so they can login / create an account - */ - useEffect(() => { - if (!session && overlayCalendarQueryParam === "true") { - toggleOverlayCalendarQueryParam(false); - setContinueWithProvider(true); - } - }, [session, overlayCalendarQueryParam, toggleOverlayCalendarQueryParam]); - if (isEmbed) { return null; } return ( <> -
-
- { - if (!session) { - setContinueWithProvider(state); - } else { - toggleOverlayCalendarQueryParam(state); - } - }} - /> - -
- {session && ( -
+ { diff --git a/packages/features/bookings/components/AvailableTimes.tsx b/packages/features/bookings/components/AvailableTimes.tsx index e46e020a6e..509056d5a3 100644 --- a/packages/features/bookings/components/AvailableTimes.tsx +++ b/packages/features/bookings/components/AvailableTimes.tsx @@ -48,7 +48,8 @@ const SlotItem = ({ }) => { const { t } = useLocale(); - const overlayCalendarToggled = getQueryParam("overlayCalendar") === "true"; + const overlayCalendarToggled = + getQueryParam("overlayCalendar") === "true" || localStorage.getItem("overlayCalendarSwitchDefault"); const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]); const selectedDuration = useBookerStore((state) => state.selectedDuration); const bookingData = useBookerStore((state) => state.bookingData); From 8c0751b1863376b71036bf9b8ded3632c9fc98f6 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Wed, 18 Oct 2023 12:36:34 +0100 Subject: [PATCH 109/120] fix: location dropdown overflow (#11967) --- apps/web/components/eventtype/EventTypeSingleLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/components/eventtype/EventTypeSingleLayout.tsx b/apps/web/components/eventtype/EventTypeSingleLayout.tsx index 2039823806..ba369f9c80 100644 --- a/apps/web/components/eventtype/EventTypeSingleLayout.tsx +++ b/apps/web/components/eventtype/EventTypeSingleLayout.tsx @@ -425,7 +425,7 @@ function EventTypeSingleLayout({
-
+
Date: Thu, 19 Oct 2023 00:13:12 +0530 Subject: [PATCH 110/120] Add /embed route for booking/[uid] (#11976) Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> --- apps/web/pages/booking/[uid].tsx | 8 ++++---- apps/web/pages/booking/[uid]/embed.tsx | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 apps/web/pages/booking/[uid]/embed.tsx diff --git a/apps/web/pages/booking/[uid].tsx b/apps/web/pages/booking/[uid].tsx index 66577403ca..f91e22c50f 100644 --- a/apps/web/pages/booking/[uid].tsx +++ b/apps/web/pages/booking/[uid].tsx @@ -1042,7 +1042,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { const parsedQuery = querySchema.safeParse(context.query); - if (!parsedQuery.success) return { notFound: true }; + if (!parsedQuery.success) return { notFound: true } as const; const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data; const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid); @@ -1100,7 +1100,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { if (!bookingInfoRaw) { return { notFound: true, - }; + } as const; } const eventTypeRaw = !bookingInfoRaw.eventTypeId @@ -1109,7 +1109,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { if (!eventTypeRaw) { return { notFound: true, - }; + } as const; } if (eventTypeRaw.seatsPerTimeSlot && !seatReferenceUid && !session) { @@ -1130,7 +1130,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { if (!eventTypeRaw.owner) return { notFound: true, - }; + } as const; eventTypeRaw.users.push({ ...eventTypeRaw.owner, }); diff --git a/apps/web/pages/booking/[uid]/embed.tsx b/apps/web/pages/booking/[uid]/embed.tsx new file mode 100644 index 0000000000..5d6b405e57 --- /dev/null +++ b/apps/web/pages/booking/[uid]/embed.tsx @@ -0,0 +1,7 @@ +import withEmbedSsr from "@lib/withEmbedSsr"; + +import { getServerSideProps as _getServerSideProps } from "../[uid]"; + +export { default } from "../[uid]"; + +export const getServerSideProps = withEmbedSsr(_getServerSideProps); From 1bf56fbe93e079f78186064bad874e56983fb6a4 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 18 Oct 2023 21:35:24 -0400 Subject: [PATCH 111/120] fix: When GCal OAuth Canceled, Do Not Create A Credential (#11987) --- .../app-store/googlecalendar/api/callback.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/app-store/googlecalendar/api/callback.ts b/packages/app-store/googlecalendar/api/callback.ts index ccc2fe296e..4c80d974ec 100644 --- a/packages/app-store/googlecalendar/api/callback.ts +++ b/packages/app-store/googlecalendar/api/callback.ts @@ -16,10 +16,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const { code } = req.query; const state = decodeOAuthState(req); - if (code && typeof code !== "string") { + if (typeof code !== "string") { res.status(400).json({ message: "`code` must be a string" }); return; } + if (!req.session?.user?.id) { return res.status(401).json({ message: "You must be logged in to do this" }); } @@ -39,16 +40,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (code) { const token = await oAuth2Client.getToken(code); key = token.res?.data; - } - await prisma.credential.create({ - data: { - type: "google_calendar", - key, - userId: req.session.user.id, - appId: "google-calendar", - }, - }); + await prisma.credential.create({ + data: { + type: "google_calendar", + key, + userId: req.session.user.id, + appId: "google-calendar", + }, + }); + } if (state?.installGoogleVideo) { const existingGoogleMeetCredential = await prisma.credential.findFirst({ From efc3e864bb4b4dd470c1d004135e0077eebf19bc Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 19 Oct 2023 08:32:56 +0530 Subject: [PATCH 112/120] fix: Missing avatar for non-migrated users on team booking page (#11977) Co-authored-by: Peer Richelsen --- apps/web/pages/api/user/avatar.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/web/pages/api/user/avatar.ts b/apps/web/pages/api/user/avatar.ts index 43cd00f0e1..fcf0ce7d09 100644 --- a/apps/web/pages/api/user/avatar.ts +++ b/apps/web/pages/api/user/avatar.ts @@ -34,13 +34,35 @@ async function getIdentityData(req: NextApiRequest) { : null; if (username) { - const user = await prisma.user.findFirst({ + let user = await prisma.user.findFirst({ where: { username, organization: orgQuery, }, select: { avatar: true, email: true }, }); + + /** + * TEMPORARY CODE STARTS - TO BE REMOVED after mono-user schema is implemented + * Try the non-org user temporarily to support users part of a team but not part of the organization + * This is needed because of a situation where we migrate a user and the team to ORG but not all the users in the team to the ORG. + * Eventually, all users will be migrated to the ORG but this is when user by user migration happens initially. + */ + // No user found in the org, try the non-org user that might be part of the team that's part of an org + if (!user && orgQuery) { + // The only side effect this code could have is that it could serve the avatar of a non-org member from the org domain but as long as the username isn't taken by an org member. + user = await prisma.user.findFirst({ + where: { + username, + organization: null, + }, + select: { avatar: true, email: true }, + }); + } + /** + * TEMPORARY CODE ENDS + */ + return { name: username, email: user?.email, @@ -48,6 +70,7 @@ async function getIdentityData(req: NextApiRequest) { org, }; } + if (teamname) { const team = await prisma.team.findFirst({ where: { From 2550485c496874f4eddda26c0befa0ff46334a2d Mon Sep 17 00:00:00 2001 From: Siddharth Movaliya Date: Thu, 19 Oct 2023 14:36:48 +0530 Subject: [PATCH 113/120] feat: Shows link location and respective icon in /bookings (#11866) Co-authored-by: Peer Richelsen Co-authored-by: Peer Richelsen --- .../components/booking/BookingListItem.tsx | 25 +++++++++++++++++++ apps/web/public/static/locales/en/common.json | 2 ++ 2 files changed, 27 insertions(+) diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index e3f4fa7b22..3be8b6f900 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -88,6 +88,10 @@ function BookingListItem(booking: BookingItemProps) { const isRecurring = booking.recurringEventId !== null; const isTabRecurring = booking.listingStatus === "recurring"; const isTabUnconfirmed = booking.listingStatus === "unconfirmed"; + const eventLocationType = getEventLocationType(booking.location); + const meetingLink = booking.references[0]?.meetingUrl + ? booking.references[0]?.meetingUrl + : booking.location; const paymentAppData = getPaymentAppData(booking.eventType); @@ -353,6 +357,27 @@ function BookingListItem(booking: BookingItemProps) { attendees={booking.attendees} />
+ {!isPending && (eventLocationType || booking.location?.startsWith("https://")) && ( + +
+ {eventLocationType ? ( + <> + {`${eventLocationType.label} + {t("join_event_location", { eventLocationType: eventLocationType.label })} + + ) : ( + t("join_meeting") + )} +
+ + )} + {isPending && ( {t("unconfirmed")} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 8d938333f3..1255c17acd 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2095,5 +2095,7 @@ "overlay_my_calendar":"Overlay my calendar", "overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.", "view_overlay_calendar_events":"View your calendar events to prevent clashed booking.", + "join_event_location": "Join {{eventLocationType}}", + "join_meeting": "Join Meeting", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } From 614741d207e99772d249db39f536c62473f1062c Mon Sep 17 00:00:00 2001 From: "gitstart-app[bot]" <57568882+gitstart-app[bot]@users.noreply.github.com> Date: Thu, 19 Oct 2023 09:27:32 -0300 Subject: [PATCH 114/120] test: Create E2E tests for bookings with custom/required Phone + other questions (#11502) Co-authored-by: gitstart-calcom Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Shivam Kalra Co-authored-by: gitstart-calcom Co-authored-by: Morgan Vernay --- .../playwright/booking/phoneQuestion.e2e.ts | 387 ++++++++++++++++++ .../playwright/fixtures/regularBookings.ts | 250 +++++++++++ apps/web/playwright/lib/fixtures.ts | 6 + 3 files changed, 643 insertions(+) create mode 100644 apps/web/playwright/booking/phoneQuestion.e2e.ts create mode 100644 apps/web/playwright/fixtures/regularBookings.ts diff --git a/apps/web/playwright/booking/phoneQuestion.e2e.ts b/apps/web/playwright/booking/phoneQuestion.e2e.ts new file mode 100644 index 0000000000..f8236c34ff --- /dev/null +++ b/apps/web/playwright/booking/phoneQuestion.e2e.ts @@ -0,0 +1,387 @@ +import { loginUser } from "../fixtures/regularBookings"; +import { test } from "../lib/fixtures"; + +test.describe("Booking With Phone Question and Each Other Question", () => { + const bookingOptions = { hasPlaceholder: true, isRequired: true }; + + test.beforeEach(async ({ page, users, bookingPage }) => { + await loginUser(users); + await page.goto("/event-types"); + await bookingPage.goToEventType("30 min"); + await bookingPage.goToTab("event_advanced_tab_title"); + }); + + test.describe("Booking With Phone Question and Address Question", () => { + test("Phone and Address required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("address", "address-test", "address test", true, "address test"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and Address question (both required)", + secondQuestion: "address", + options: bookingOptions, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone and Address not required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("address", "address-test", "address test", false, "address test"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and Address question (only phone required)", + secondQuestion: "address", + options: { ...bookingOptions, isRequired: false }, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + + test.describe("Booking With Phone Question and checkbox group Question", () => { + const bookingOptions = { hasPlaceholder: false, isRequired: true }; + test("Phone and checkbox group required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", true); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and checkbox group question (both required)", + secondQuestion: "checkbox", + options: bookingOptions, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone and checkbox group not required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and checkbox group question (only phone required)", + secondQuestion: "checkbox", + options: { ...bookingOptions, isRequired: false }, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + }); + + test.describe("Booking With Phone Question and checkbox Question", () => { + test("Phone and checkbox required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", true); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and checkbox question (both required)", + secondQuestion: "boolean", + options: bookingOptions, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + test("Phone and checkbox not required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and checkbox (only phone required)", + secondQuestion: "boolean", + options: { ...bookingOptions, isRequired: false }, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + }); + + test.describe("Booking With Phone Question and Long text Question", () => { + test("Phone and Long text required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and Long Text question (both required)", + secondQuestion: "textarea", + options: bookingOptions, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone and Long text not required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", false, "textarea test"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and Long Text question (only phone required)", + secondQuestion: "textarea", + options: { ...bookingOptions, isRequired: false }, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + }); + + test.describe("Booking With Phone Question and Multi email Question", () => { + const bookingOptions = { hasPlaceholder: true, isRequired: true }; + test("Phone and Multi email required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion( + "multiemail", + "multiemail-test", + "multiemail test", + true, + "multiemail test" + ); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and Multi Email question (both required)", + secondQuestion: "multiemail", + options: bookingOptions, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone and Multi email not required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion( + "multiemail", + "multiemail-test", + "multiemail test", + false, + "multiemail test" + ); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and Multi Email question (only phone required)", + secondQuestion: "multiemail", + options: { ...bookingOptions, isRequired: false }, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + }); + + test.describe("Booking With Phone Question and multiselect Question", () => { + test("Phone and multiselect text required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and Multi Select question (both required)", + secondQuestion: "multiselect", + options: bookingOptions, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone and multiselect text not required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and Multi Select question (only phone required)", + secondQuestion: "multiselect", + options: { ...bookingOptions, isRequired: false }, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + }); + + test.describe("Booking With Phone Question and Number Question", () => { + test("Phone and Number required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("number", "number-test", "number test", true, "number test"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and Number question (both required)", + secondQuestion: "number", + options: bookingOptions, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone and Number not required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("number", "number-test", "number test", false, "number test"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and Number question (only phone required)", + secondQuestion: "number", + options: { ...bookingOptions, isRequired: false }, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + }); + + test.describe("Booking With Phone Question and Radio group Question", () => { + test("Phone and Radio group required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("radio", "radio-test", "radio test", true); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and Radio question (both required)", + secondQuestion: "radio", + options: bookingOptions, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone and Radio group not required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("radio", "radio-test", "radio test", false); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and Radio question (only phone required)", + secondQuestion: "radio", + options: { ...bookingOptions, isRequired: false }, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + }); + + test.describe("Booking With Phone Question and select Question", () => { + test("Phone and select required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("select", "select-test", "select test", true, "select test"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and Select question (both required)", + secondQuestion: "select", + options: bookingOptions, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone and select not required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("select", "select-test", "select test", false, "select test"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and Select question (only phone required)", + secondQuestion: "select", + options: { ...bookingOptions, isRequired: false }, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + }); + + test.describe("Booking With Phone Question and Short text question", () => { + const bookingOptions = { hasPlaceholder: true, isRequired: true }; + test("Phone and Short text required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("text", "text-test", "text test", true, "text test"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and Text question (both required)", + secondQuestion: "text", + options: bookingOptions, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone and Short text not required", async ({ bookingPage }) => { + await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test"); + await bookingPage.addQuestion("text", "text-test", "text test", false, "text test"); + await bookingPage.updateEventType(); + const eventTypePage = await bookingPage.previewEventType(); + await bookingPage.selectTimeSlot(eventTypePage); + await bookingPage.fillAndConfirmBooking({ + eventTypePage, + placeholderText: "Please share anything that will help prepare for our meeting.", + question: "phone", + fillText: "Test Phone question and Text question (only phone required)", + secondQuestion: "text", + options: { ...bookingOptions, isRequired: false }, + }); + await bookingPage.cancelAndRescheduleBooking(eventTypePage); + }); + }); + }); +}); diff --git a/apps/web/playwright/fixtures/regularBookings.ts b/apps/web/playwright/fixtures/regularBookings.ts new file mode 100644 index 0000000000..447debd83a --- /dev/null +++ b/apps/web/playwright/fixtures/regularBookings.ts @@ -0,0 +1,250 @@ +import { expect, type Page } from "@playwright/test"; + +import type { createUsersFixture } from "./users"; + +const reschedulePlaceholderText = "Let others know why you need to reschedule"; +export const scheduleSuccessfullyText = "This meeting is scheduled"; + +const EMAIL = "test@test.com"; +const EMAIL2 = "test2@test.com"; +const PHONE = "+55 (32) 983289947"; + +type BookingOptions = { + hasPlaceholder?: boolean; + isReschedule?: boolean; + isRequired?: boolean; +}; + +interface QuestionActions { + [key: string]: () => Promise; +} + +type customLocators = { + shouldChangeSelectLocator: boolean; + shouldUseLastRadioGroupLocator: boolean; + shouldUseFirstRadioGroupLocator: boolean; + shouldChangeMultiSelectLocator: boolean; +}; + +type fillAndConfirmBookingParams = { + eventTypePage: Page; + placeholderText: string; + question: string; + fillText: string; + secondQuestion: string; + options: BookingOptions; +}; + +type UserFixture = ReturnType; + +const fillQuestion = async (eventTypePage: Page, questionType: string, customLocators: customLocators) => { + const questionActions: QuestionActions = { + phone: async () => { + await eventTypePage.locator('input[name="phone-test"]').clear(); + await eventTypePage.locator('input[name="phone-test"]').fill(PHONE); + }, + multiemail: async () => { + await eventTypePage.getByRole("button", { name: `${questionType} test` }).click(); + await eventTypePage.getByPlaceholder(`${questionType} test`).fill(EMAIL); + await eventTypePage.getByTestId("add-another-guest").last().click(); + await eventTypePage.getByPlaceholder(`${questionType} test`).last().fill(EMAIL2); + }, + checkbox: async () => { + if (customLocators.shouldUseLastRadioGroupLocator || customLocators.shouldChangeMultiSelectLocator) { + await eventTypePage.getByLabel("Option 1").last().click(); + await eventTypePage.getByLabel("Option 2").last().click(); + } else if (customLocators.shouldUseFirstRadioGroupLocator) { + await eventTypePage.getByLabel("Option 1").first().click(); + await eventTypePage.getByLabel("Option 2").first().click(); + } else { + await eventTypePage.getByLabel("Option 1").click(); + await eventTypePage.getByLabel("Option 2").click(); + } + }, + multiselect: async () => { + if (customLocators.shouldChangeMultiSelectLocator) { + await eventTypePage.locator("form svg").nth(1).click(); + await eventTypePage.getByTestId("select-option-Option 1").click(); + } else { + await eventTypePage.locator("form svg").last().click(); + await eventTypePage.getByTestId("select-option-Option 1").click(); + } + }, + boolean: async () => { + await eventTypePage.getByLabel(`${questionType} test`).check(); + }, + radio: async () => { + await eventTypePage.locator('[id="radio-test\\.option\\.0\\.radio"]').click(); + }, + select: async () => { + if (customLocators.shouldChangeSelectLocator) { + await eventTypePage.locator("form svg").nth(1).click(); + await eventTypePage.getByTestId("select-option-Option 1").click(); + } else { + await eventTypePage.locator("form svg").last().click(); + await eventTypePage.getByTestId("select-option-Option 1").click(); + } + }, + number: async () => { + await eventTypePage.getByPlaceholder(`${questionType} test`).click(); + await eventTypePage.getByPlaceholder(`${questionType} test`).fill("123"); + }, + address: async () => { + await eventTypePage.getByPlaceholder(`${questionType} test`).click(); + await eventTypePage.getByPlaceholder(`${questionType} test`).fill("address test"); + }, + textarea: async () => { + await eventTypePage.getByPlaceholder(`${questionType} test`).click(); + await eventTypePage.getByPlaceholder(`${questionType} test`).fill("textarea test"); + }, + text: async () => { + await eventTypePage.getByPlaceholder(`${questionType} test`).click(); + await eventTypePage.getByPlaceholder(`${questionType} test`).fill("text test"); + }, + }; + + if (questionActions[questionType]) { + await questionActions[questionType](); + } +}; + +export async function loginUser(users: UserFixture) { + const pro = await users.create({ name: "testuser" }); + await pro.apiLogin(); +} + +export function createBookingPageFixture(page: Page) { + return { + goToEventType: async (eventType: string) => { + await page.getByRole("link", { name: eventType }).click(); + }, + goToTab: async (tabName: string) => { + await page.getByTestId(`vertical-tab-${tabName}`).click(); + }, + addQuestion: async ( + questionType: string, + identifier: string, + label: string, + isRequired: boolean, + placeholder?: string + ) => { + await page.getByTestId("add-field").click(); + await page.locator("#test-field-type > .bg-default > div > div:nth-child(2)").first().click(); + await page.getByTestId(`select-option-${questionType}`).click(); + await page.getByLabel("Identifier").dblclick(); + await page.getByLabel("Identifier").fill(identifier); + await page.getByLabel("Label").click(); + await page.getByLabel("Label").fill(label); + if (placeholder) { + await page.getByLabel("Placeholder").click(); + await page.getByLabel("Placeholder").fill(placeholder); + } + if (!isRequired) { + await page.getByRole("radio", { name: "No" }).click(); + } + await page.getByTestId("field-add-save").click(); + }, + updateEventType: async () => { + await page.getByTestId("update-eventtype").click(); + }, + previewEventType: async () => { + const eventtypePromise = page.waitForEvent("popup"); + await page.getByTestId("preview-button").click(); + return eventtypePromise; + }, + selectTimeSlot: async (eventTypePage: Page) => { + while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) { + await eventTypePage.getByRole("button", { name: "View next" }).click(); + } + await eventTypePage.getByTestId("time").first().click(); + }, + clickReschedule: async () => { + await page.getByText("Reschedule").click(); + }, + navigateToAvailableTimeSlot: async () => { + while (await page.getByRole("button", { name: "View next" }).isVisible()) { + await page.getByRole("button", { name: "View next" }).click(); + } + }, + selectFirstAvailableTime: async () => { + await page.getByTestId("time").first().click(); + }, + fillRescheduleReasonAndConfirm: async () => { + await page.getByPlaceholder(reschedulePlaceholderText).click(); + await page.getByPlaceholder(reschedulePlaceholderText).fill("Test reschedule"); + await page.getByTestId("confirm-reschedule-button").click(); + }, + verifyReschedulingSuccess: async () => { + await expect(page.getByText(scheduleSuccessfullyText)).toBeVisible(); + }, + cancelBookingWithReason: async () => { + await page.getByTestId("cancel").click(); + await page.getByTestId("cancel_reason").fill("Test cancel"); + await page.getByTestId("confirm_cancel").click(); + }, + verifyBookingCancellation: async () => { + await expect(page.getByTestId("cancelled-headline")).toBeVisible(); + }, + cancelAndRescheduleBooking: async (eventTypePage: Page) => { + await eventTypePage.getByText("Reschedule").click(); + while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) { + await eventTypePage.getByRole("button", { name: "View next" }).click(); + } + await eventTypePage.getByTestId("time").first().click(); + await eventTypePage.getByPlaceholder(reschedulePlaceholderText).click(); + await eventTypePage.getByPlaceholder(reschedulePlaceholderText).fill("Test reschedule"); + await eventTypePage.getByTestId("confirm-reschedule-button").click(); + await expect(eventTypePage.getByText(scheduleSuccessfullyText)).toBeVisible(); + await eventTypePage.getByTestId("cancel").click(); + await eventTypePage.getByTestId("cancel_reason").fill("Test cancel"); + await eventTypePage.getByTestId("confirm_cancel").click(); + await expect(eventTypePage.getByTestId("cancelled-headline")).toBeVisible(); + }, + + fillAndConfirmBooking: async ({ + eventTypePage, + placeholderText, + question, + fillText, + secondQuestion, + options, + }: fillAndConfirmBookingParams) => { + const confirmButton = options.isReschedule ? "confirm-reschedule-button" : "confirm-book-button"; + + await expect(eventTypePage.getByText(`${secondQuestion} test`).first()).toBeVisible(); + await eventTypePage.getByPlaceholder(placeholderText).fill(fillText); + + // Change the selector for specifics cases related to select question + const shouldChangeSelectLocator = (question: string, secondQuestion: string): boolean => + question === "select" && ["multiemail", "multiselect"].includes(secondQuestion); + + const shouldUseLastRadioGroupLocator = (question: string, secondQuestion: string): boolean => + question === "radio" && secondQuestion === "checkbox"; + + const shouldUseFirstRadioGroupLocator = (question: string, secondQuestion: string): boolean => + question === "checkbox" && secondQuestion === "radio"; + + const shouldChangeMultiSelectLocator = (question: string, secondQuestion: string): boolean => + question === "multiselect" && + ["address", "checkbox", "multiemail", "select"].includes(secondQuestion); + + const customLocators = { + shouldChangeSelectLocator: shouldChangeSelectLocator(question, secondQuestion), + shouldUseLastRadioGroupLocator: shouldUseLastRadioGroupLocator(question, secondQuestion), + shouldUseFirstRadioGroupLocator: shouldUseFirstRadioGroupLocator(question, secondQuestion), + shouldChangeMultiSelectLocator: shouldChangeMultiSelectLocator(question, secondQuestion), + }; + + // Fill the first question + await fillQuestion(eventTypePage, question, customLocators); + + // Fill the second question if is required + options.isRequired && (await fillQuestion(eventTypePage, secondQuestion, customLocators)); + + await eventTypePage.getByTestId(confirmButton).click(); + const scheduleSuccessfullyPage = eventTypePage.getByText(scheduleSuccessfullyText); + await scheduleSuccessfullyPage.waitFor({ state: "visible" }); + await expect(scheduleSuccessfullyPage).toBeVisible(); + }, + }; +} diff --git a/apps/web/playwright/lib/fixtures.ts b/apps/web/playwright/lib/fixtures.ts index 2c9cb71216..61d315a754 100644 --- a/apps/web/playwright/lib/fixtures.ts +++ b/apps/web/playwright/lib/fixtures.ts @@ -10,6 +10,7 @@ import type { ExpectedUrlDetails } from "../../../../playwright.config"; import { createBookingsFixture } from "../fixtures/bookings"; import { createEmbedsFixture } from "../fixtures/embeds"; import { createPaymentsFixture } from "../fixtures/payments"; +import { createBookingPageFixture } from "../fixtures/regularBookings"; import { createRoutingFormsFixture } from "../fixtures/routingForms"; import { createServersFixture } from "../fixtures/servers"; import { createUsersFixture } from "../fixtures/users"; @@ -24,6 +25,7 @@ export interface Fixtures { prisma: typeof prisma; emails?: API; routingForms: ReturnType; + bookingPage: ReturnType; } declare global { @@ -80,4 +82,8 @@ export const test = base.extend({ await use(undefined); } }, + bookingPage: async ({ page }, use) => { + const bookingPage = createBookingPageFixture(page); + await use(bookingPage); + }, }); From 4b818de0c876ea213c7fbf397c9ccf8761e4d540 Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Date: Thu, 19 Oct 2023 17:31:13 +0400 Subject: [PATCH 115/120] feat: Allow hideBranding via public API (#11978) --- apps/api/lib/validations/user.ts | 4 ++++ apps/api/pages/api/users/[userId]/_patch.ts | 12 ++++++++---- apps/api/pages/api/users/_post.ts | 3 +++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/api/lib/validations/user.ts b/apps/api/lib/validations/user.ts index 107db36ba6..de9e22a976 100644 --- a/apps/api/lib/validations/user.ts +++ b/apps/api/lib/validations/user.ts @@ -75,6 +75,7 @@ export const schemaUserBaseBodyParams = User.pick({ theme: true, defaultScheduleId: true, locale: true, + hideBranding: true, timeFormat: true, brandColor: true, darkBrandColor: true, @@ -95,6 +96,7 @@ const schemaUserEditParams = z.object({ weekStart: z.nativeEnum(weekdays).optional(), brandColor: z.string().min(4).max(9).regex(/^#/).optional(), darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(), + hideBranding: z.boolean().optional(), timeZone: timeZone.optional(), theme: z.nativeEnum(theme).optional().nullable(), timeFormat: z.nativeEnum(timeFormat).optional(), @@ -115,6 +117,7 @@ const schemaUserCreateParams = z.object({ weekStart: z.nativeEnum(weekdays).optional(), brandColor: z.string().min(4).max(9).regex(/^#/).optional(), darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(), + hideBranding: z.boolean().optional(), timeZone: timeZone.optional(), theme: z.nativeEnum(theme).optional().nullable(), timeFormat: z.nativeEnum(timeFormat).optional(), @@ -157,6 +160,7 @@ export const schemaUserReadPublic = User.pick({ defaultScheduleId: true, locale: true, timeFormat: true, + hideBranding: true, brandColor: true, darkBrandColor: true, allowDynamicBooking: true, diff --git a/apps/api/pages/api/users/[userId]/_patch.ts b/apps/api/pages/api/users/[userId]/_patch.ts index 59d8b76f94..84f6ffb45b 100644 --- a/apps/api/pages/api/users/[userId]/_patch.ts +++ b/apps/api/pages/api/users/[userId]/_patch.ts @@ -53,6 +53,9 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation * timeZone: * description: The user's time zone * type: string + * hideBranding: + * description: Remove branding from the user's calendar page + * type: boolean * theme: * description: Default theme for the user. Acceptable values are one of [DARK, LIGHT] * type: string @@ -79,7 +82,7 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation * - users * responses: * 200: - * description: OK, user edited successfuly + * description: OK, user edited successfully * 400: * description: Bad request. User body is invalid. * 401: @@ -94,9 +97,10 @@ export async function patchHandler(req: NextApiRequest) { if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" }); const body = await schemaUserEditBodyParams.parseAsync(req.body); - // disable role changes unless admin. - if (!isAdmin && body.role) { - body.role = undefined; + // disable role or branding changes unless admin. + if (!isAdmin) { + if (body.role) body.role = undefined; + if (body.hideBranding) body.hideBranding = undefined; } const userSchedules = await prisma.schedule.findMany({ diff --git a/apps/api/pages/api/users/_post.ts b/apps/api/pages/api/users/_post.ts index 7c945399d0..15c68aa31d 100644 --- a/apps/api/pages/api/users/_post.ts +++ b/apps/api/pages/api/users/_post.ts @@ -42,6 +42,9 @@ import { schemaUserCreateBodyParams } from "~/lib/validations/user"; * darkBrandColor: * description: The new user's brand color for dark mode * type: string + * hideBranding: + * description: Remove branding from the user's calendar page + * type: boolean * weekStart: * description: Start of the week. Acceptable values are one of [SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY] * type: string From feda420f0c67bc88b2cf1ed2dc1114ca2121ed00 Mon Sep 17 00:00:00 2001 From: Nafees Nazik <84864519+G3root@users.noreply.github.com> Date: Thu, 19 Oct 2023 19:14:43 +0530 Subject: [PATCH 116/120] fix: org team page not found with uppercase letters (#11737) Co-authored-by: Peer Richelsen --- packages/features/ee/organizations/lib/orgDomains.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/features/ee/organizations/lib/orgDomains.ts b/packages/features/ee/organizations/lib/orgDomains.ts index 0d8051ffb7..68f6425fad 100644 --- a/packages/features/ee/organizations/lib/orgDomains.ts +++ b/packages/features/ee/organizations/lib/orgDomains.ts @@ -1,6 +1,7 @@ import type { Prisma } from "@prisma/client"; import { ALLOWED_HOSTNAMES, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants"; +import slugify from "@calcom/lib/slugify"; /** * return the org slug @@ -52,13 +53,14 @@ export function getOrgFullDomain(slug: string, options: { protocol: boolean } = } export function getSlugOrRequestedSlug(slug: string) { + const slugifiedValue = slugify(slug); return { OR: [ - { slug }, + { slug: slugifiedValue }, { metadata: { path: ["requestedSlug"], - equals: slug, + equals: slugifiedValue, }, }, ], From 9b348adb6a7d2eb422c98e96ca0ec9ff7547d9a8 Mon Sep 17 00:00:00 2001 From: Surya Ashish Date: Thu, 19 Oct 2023 19:22:10 +0530 Subject: [PATCH 117/120] fix: added loading state to button (#11624) Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Peer Richelsen Co-authored-by: Udit Takkar --- apps/web/pages/auth/logout.tsx | 14 ++++++++++++-- apps/web/playwright/login.e2e.ts | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/web/pages/auth/logout.tsx b/apps/web/pages/auth/logout.tsx index 43990d6eff..b0f5d87d20 100644 --- a/apps/web/pages/auth/logout.tsx +++ b/apps/web/pages/auth/logout.tsx @@ -1,7 +1,7 @@ import type { GetServerSidePropsContext } from "next"; import { signOut, useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { WEBSITE_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -18,6 +18,7 @@ import { ssrInit } from "@server/lib/ssr"; type Props = inferSSRProps; export function Logout(props: Props) { + const [btnLoading, setBtnLoading] = useState(false); const { status } = useSession(); if (status === "authenticated") signOut({ redirect: false }); const router = useRouter(); @@ -35,6 +36,11 @@ export function Logout(props: Props) { return "hope_to_see_you_soon"; }; + const navigateToLogin = () => { + setBtnLoading(true); + router.push("/auth/login"); + }; + return (
@@ -50,7 +56,11 @@ export function Logout(props: Props) {
- diff --git a/apps/web/playwright/login.e2e.ts b/apps/web/playwright/login.e2e.ts index 13f0cdd049..94569128f8 100644 --- a/apps/web/playwright/login.e2e.ts +++ b/apps/web/playwright/login.e2e.ts @@ -35,7 +35,7 @@ test.describe("user can login & logout succesfully", async () => { const signOutBtn = await page.locator(`text=${signOutLabel}`); await signOutBtn.click(); - await page.locator('a[href="/auth/login"]').click(); + await page.locator("[data-testid=logout-btn]").click(); // Reroute to the home page to check if the login form shows up await expect(page.locator(`[data-testid=login-form]`)).toBeVisible(); From 6b6d3d90e471be866bca963c11935d41da235bb1 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Thu, 19 Oct 2023 15:09:06 +0100 Subject: [PATCH 118/120] feat/use-primary-by-default-overlay (#11935) --- .../OverlayCalendarSettingsModal.tsx | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx index 24ccc80a73..118265be69 100644 --- a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx +++ b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { Fragment } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Fragment, useEffect, useState } from "react"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -43,17 +43,35 @@ const SkeletonLoader = () => { export function OverlayCalendarSettingsModal(props: IOverlayCalendarContinueModalProps) { const utils = trpc.useContext(); + const [initalised, setInitalised] = useState(false); + const searchParams = useSearchParams(); const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates); const { data, isLoading } = trpc.viewer.connectedCalendars.useQuery(undefined, { - enabled: !!props.open, + enabled: !!props.open || !!searchParams.get("overlayCalendar"), }); - const { toggleValue, hasItem } = useLocalSet<{ + const { toggleValue, hasItem, set } = useLocalSet<{ credentialId: number; externalId: string; }>("toggledConnectedCalendars", []); const router = useRouter(); const { t } = useLocale(); + + useEffect(() => { + if (data?.connectedCalendars && set.size === 0 && !initalised) { + data?.connectedCalendars.forEach((item) => { + item.calendars?.forEach((cal) => { + const id = { credentialId: item.credentialId, externalId: cal.externalId }; + if (cal.primary) { + toggleValue(id); + } + }); + }); + setInitalised(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, hasItem, set, initalised]); + return ( <> From 5e3c0cdea1b9e13a259832f84c29fb264592242e Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Thu, 19 Oct 2023 12:31:00 -0300 Subject: [PATCH 119/120] v3.4.2 --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index 6ba03c5b9b..245026e66e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@calcom/web", - "version": "3.4.1", + "version": "3.4.2", "private": true, "scripts": { "analyze": "ANALYZE=true next build", From e2414b174acdd99e7c860e8d82337f4952745c01 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Fri, 20 Oct 2023 00:05:34 +0530 Subject: [PATCH 120/120] Handle non-org team with same slug as the organizations requestedSlug (#11996) --- apps/web/next.config.js | 2 +- apps/web/pages/team/[slug].tsx | 31 +++++++++++++------ packages/lib/server/queries/teams/index.ts | 24 +++++++++++++- .../viewer/organizations/create.handler.ts | 12 +++---- 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 262e1f6e5a..22da1946e5 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -235,7 +235,7 @@ const nextConfig = { ? [ { ...matcherConfigRootPath, - destination: "/team/:orgSlug", + destination: "/team/:orgSlug?isOrgProfile=1", }, { ...matcherConfigUserRoute, diff --git a/apps/web/pages/team/[slug].tsx b/apps/web/pages/team/[slug].tsx index 3d6c4bc7bb..73c69acd2f 100644 --- a/apps/web/pages/team/[slug].tsx +++ b/apps/web/pages/team/[slug].tsx @@ -1,3 +1,9 @@ +// This route is reachable by +// 1. /team/[slug] +// 2. / (when on org domain e.g. http://calcom.cal.com/. This is through a rewrite from next.config.js) +// Also the getServerSideProps and default export are reused by +// 1. org/[orgSlug]/team/[slug] +// 2. org/[orgSlug]/[user]/[type] import classNames from "classnames"; import type { GetServerSidePropsContext } from "next"; import Link from "next/link"; @@ -12,6 +18,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; import useTheme from "@calcom/lib/hooks/useTheme"; +import logger from "@calcom/lib/logger"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; import { getTeamWithMembers } from "@calcom/lib/server/queries/teams"; import slugify from "@calcom/lib/slugify"; @@ -34,7 +41,7 @@ import { ssrInit } from "@server/lib/ssr"; import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect"; export type PageProps = inferSSRProps; - +const log = logger.getSubLogger({ prefix: ["team/[slug]"] }); function TeamPage({ team, isUnpublished, @@ -277,12 +284,23 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => ); const isOrgContext = isValidOrgDomain && currentOrgDomain; + // Provided by Rewrite from next.config.js + const isOrgProfile = context.query?.isOrgProfile === "1"; const flags = await getFeatureFlagMap(prisma); + const isOrganizationFeatureEnabled = flags["organizations"]; + + log.debug("getServerSideProps", { + isOrgProfile, + isOrganizationFeatureEnabled, + isValidOrgDomain, + currentOrgDomain, + }); + const team = await getTeamWithMembers({ slug: slugify(slug ?? ""), orgSlug: currentOrgDomain, isTeamView: true, - isOrgView: isValidOrgDomain && context.resolvedUrl === "/", + isOrgView: isValidOrgDomain && isOrgProfile, }); if (!isOrgContext && slug) { @@ -299,17 +317,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const ssr = await ssrInit(context); const metadata = teamMetadataSchema.parse(team?.metadata ?? {}); - console.info("gSSP, team/[slug] - ", { - isValidOrgDomain, - currentOrgDomain, - ALLOWED_HOSTNAMES: process.env.ALLOWED_HOSTNAMES, - flags: JSON.stringify(flags), - }); + // Taking care of sub-teams and orgs if ( (!isValidOrgDomain && team?.parent) || (!isValidOrgDomain && !!metadata?.isOrganization) || - flags["organizations"] !== true + !isOrganizationFeatureEnabled ) { return { notFound: true } as const; } diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index 6cf5e6f0d7..2d1fe4189b 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -7,6 +7,7 @@ import { SchedulingType } from "@calcom/prisma/enums"; import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { WEBAPP_URL } from "../../../constants"; +import logger from "../../../logger"; export type TeamWithMembers = Awaited>; @@ -17,6 +18,9 @@ export async function getTeamWithMembers(args: { orgSlug?: string | null; includeTeamLogo?: boolean; isTeamView?: boolean; + /** + * If true, means that you are fetching an organization and not a team + */ isOrgView?: boolean; }) { const { id, slug, userId, orgSlug, isTeamView, isOrgView, includeTeamLogo } = args; @@ -120,12 +124,30 @@ export async function getTeamWithMembers(args: { } if (id) where.id = id; if (slug) where.slug = slug; + if (isOrgView) { + // We must fetch only the organization here. + // Note that an organization and a team that doesn't belong to an organization, both have parentId null + // If the organization has null slug(but requestedSlug is 'test') and the team also has slug 'test', we can't distinguish them without explicitly checking the metadata.isOrganization + // Note that, this isn't possible now to have same requestedSlug as the slug of a team not part of an organization. This is legacy teams handling mostly. But it is still safer to be sure that you are fetching an Organization only in case of isOrgView + where.metadata = { + path: ["isOrganization"], + equals: true, + }; + } - const team = await prisma.team.findFirst({ + const teams = await prisma.team.findMany({ where, select: teamSelect, }); + if (teams.length > 1) { + logger.error("Found more than one team/Org. We should be doing something wrong.", { + where, + teams: teams.map((team) => ({ id: team.id, slug: team.slug })), + }); + } + + const team = teams[0]; if (!team) return null; // This should improve performance saving already app data found. diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts index 743805fd11..1e0f9a2e03 100644 --- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts @@ -72,17 +72,17 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => { }, }); - const slugCollisions = await prisma.team.findFirst({ + // An org doesn't have a parentId. A team that isn't part of an org also doesn't have a parentId. + // So, an org can't have the same slug as a non-org team. + // There is a unique index on [slug, parentId] in Team because we don't add the slug to the team always. We only add metadata.requestedSlug in some cases. So, DB won't prevent creation of such an organization. + const hasANonOrgTeamOrOrgWithSameSlug = await prisma.team.findFirst({ where: { slug: slug, - metadata: { - path: ["isOrganization"], - equals: true, - }, + parentId: null, }, }); - if (slugCollisions || RESERVED_SUBDOMAINS.includes(slug)) + if (hasANonOrgTeamOrOrgWithSameSlug || RESERVED_SUBDOMAINS.includes(slug)) throw new TRPCError({ code: "BAD_REQUEST", message: "organization_url_taken" }); if (userCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "admin_email_taken" });