@@ -110,6 +120,7 @@
Floating Popup
Popup Examples
+
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
(".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>;
type setNonStylesConfig = React.Dispatch>;
+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> & {
+ // TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app.
+ iframeAttrs?: Record & {
+ 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) {
+ 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,
+ 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, ...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> & {
- // TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app.
- iframeAttrs?: Record & {
- 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;
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 = ``;
-
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,
- 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
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[];
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 & {
+ 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>;
+ schedule?: InputUser["schedules"][number];
+} & Partial>;
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 & {
+ 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;
+ // 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: "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 (
+ <>
+
+ >
+ );
+}
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 (
+ <>
+
+ >
+ );
+}
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 && (
+
+
+
+
+
+
+
+
+
+
+
+ {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 ? (
+ <>
+
+ {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) {
-