From a53ea33168d75f377cc7841ea9a559b73a10a9ed Mon Sep 17 00:00:00 2001 From: zomars Date: Mon, 9 Oct 2023 19:55:54 -0700 Subject: [PATCH 1/6] fix: API build --- apps/api/pages/api/event-types/[id]/_patch.ts | 2 ++ apps/api/pages/api/event-types/_post.ts | 2 ++ apps/api/pages/api/event-types/_utils/checkUserMembership.ts | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/api/pages/api/event-types/[id]/_patch.ts b/apps/api/pages/api/event-types/[id]/_patch.ts index f70bd28407..7c8fcd480a 100644 --- a/apps/api/pages/api/event-types/[id]/_patch.ts +++ b/apps/api/pages/api/event-types/[id]/_patch.ts @@ -209,6 +209,8 @@ export async function patchHandler(req: NextApiRequest) { hosts = [], bookingLimits, durationLimits, + /** FIXME: Updating event-type children from API not supported for now */ + children: _, ...parsedBody } = schemaEventTypeEditBodyParams.parse(body); diff --git a/apps/api/pages/api/event-types/_post.ts b/apps/api/pages/api/event-types/_post.ts index 075ed4c71a..f81af6171a 100644 --- a/apps/api/pages/api/event-types/_post.ts +++ b/apps/api/pages/api/event-types/_post.ts @@ -268,6 +268,8 @@ async function postHandler(req: NextApiRequest) { hosts = [], bookingLimits, durationLimits, + /** FIXME: Adding event-type children from API not supported for now */ + children: _, ...parsedBody } = schemaEventTypeCreateBodyParams.parse(body || {}); diff --git a/apps/api/pages/api/event-types/_utils/checkUserMembership.ts b/apps/api/pages/api/event-types/_utils/checkUserMembership.ts index df819bc95e..ad449b42b3 100644 --- a/apps/api/pages/api/event-types/_utils/checkUserMembership.ts +++ b/apps/api/pages/api/event-types/_utils/checkUserMembership.ts @@ -11,7 +11,7 @@ import { HttpError } from "@calcom/lib/http-error"; * if the event type doesn't belong to any team, * or if the user isn't a member of the associated team. */ -export default async function checkUserMembership(parentId: number, userId: number) { +export default async function checkUserMembership(parentId: number, userId?: number) { const parentEventType = await prisma.eventType.findUnique({ where: { id: parentId, From 1456e2d4d57af6812a73401441b9f8313b195292 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Tue, 10 Oct 2023 08:40:04 +0530 Subject: [PATCH 2/6] feat: Embed - Introduce `prerender` instruction - Lightning fast popup experience (#11303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Peer Richelsen Co-authored-by: Omar López --- apps/web/playwright/fixtures/embeds.ts | 141 ++++++------ apps/web/playwright/lib/fixtures.ts | 14 +- apps/web/playwright/lib/testUtils.ts | 4 +- packages/embeds/.eslintrc.js | 1 + packages/embeds/embed-core/index.html | 19 +- packages/embeds/embed-core/playground.ts | 31 ++- .../embed-core/playwright/lib/testUtils.ts | 10 +- .../playwright/tests/action-based.e2e.ts | 93 +++++--- .../embed-core/playwright/tests/inline.e2e.ts | 3 +- .../embed-core/src/ModalBox/ModalBox.ts | 27 ++- .../embeds/embed-core/src/embed-iframe.ts | 205 +++++++++++++----- packages/embeds/embed-core/src/embed.ts | 192 +++++++++++----- .../embed-react/playwright/tests/basic.e2e.ts | 3 +- playwright.config.ts | 37 +++- 14 files changed, 543 insertions(+), 237 deletions(-) diff --git a/apps/web/playwright/fixtures/embeds.ts b/apps/web/playwright/fixtures/embeds.ts index 35e33ae21a..5c867dcfbe 100644 --- a/apps/web/playwright/fixtures/embeds.ts +++ b/apps/web/playwright/fixtures/embeds.ts @@ -1,81 +1,90 @@ import type { Page } from "@playwright/test"; export const createEmbedsFixture = (page: Page) => { - return async (calNamespace: string) => { - await page.addInitScript( - ({ calNamespace }: { calNamespace: string }) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - window.eventsFiredStoreForPlaywright = window.eventsFiredStoreForPlaywright || {}; - document.addEventListener("DOMContentLoaded", function tryAddingListener() { - if (parent !== window) { - // Firefox seems to execute this snippet for iframe as well. Avoid that. It must be executed only for parent frame. - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - window.initialBodyVisibility = document.body.style.visibility; - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - window.initialBodyBackground = document.body.style.background; - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - window.initialValuesSet = true; - - return; - } - + return { + /** + * @deprecated Use gotoPlayground instead + */ + async addEmbedListeners(calNamespace: string) { + await page.addInitScript( + ({ calNamespace }: { calNamespace: string }) => { + console.log("PlaywrightTest:", "Adding listener for __iframeReady on namespace:", calNamespace); // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore - let api = window.Cal; + window.eventsFiredStoreForPlaywright = window.eventsFiredStoreForPlaywright || {}; + document.addEventListener("DOMContentLoaded", function tryAddingListener() { + if (parent !== window) { + // Firefox seems to execute this snippet for iframe as well. Avoid that. It must be executed only for parent frame. + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + window.initialBodyVisibility = document.body.style.visibility; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + window.initialBodyBackground = document.body.style.background; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + window.initialValuesSet = true; + return; + } - if (!api) { - setTimeout(tryAddingListener, 500); - return; - } - if (calNamespace) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore - api = window.Cal.ns[calNamespace]; - } - console.log("PlaywrightTest:", "Adding listener for __iframeReady"); - if (!api) { - throw new Error(`namespace "${calNamespace}" not found`); - } - api("on", { - action: "*", - callback: (e) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - window.iframeReady = true; // Technically if there are multiple cal embeds, it can be set due to some other iframe. But it works for now. Improve it when it doesn't work + let api = window.Cal; + if (!api) { + console.log("PlaywrightTest:", "window.Cal not available yet, trying again"); + setTimeout(tryAddingListener, 500); + return; + } + if (calNamespace) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const store = window.eventsFiredStoreForPlaywright; - const eventStore = (store[`${e.detail.type}-${e.detail.namespace}`] = - store[`${e.detail.type}-${e.detail.namespace}`] || []); - eventStore.push(e.detail); - }, + //@ts-ignore + api = window.Cal.ns[calNamespace]; + } + console.log("PlaywrightTest:", `Adding listener for __iframeReady on namespace:${calNamespace}`); + if (!api) { + throw new Error(`namespace "${calNamespace}" not found`); + } + api("on", { + action: "*", + callback: (e) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + window.iframeReady = true; // Technically if there are multiple cal embeds, it can be set due to some other iframe. But it works for now. Improve it when it doesn't work + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const store = window.eventsFiredStoreForPlaywright; + const eventStore = (store[`${e.detail.type}-${e.detail.namespace}`] = + store[`${e.detail.type}-${e.detail.namespace}`] || []); + eventStore.push(e.detail); + }, + }); }); - }); - }, - { calNamespace } - ); - }; -}; - -export const createGetActionFiredDetails = (page: Page) => { - return async ({ calNamespace, actionType }: { calNamespace: string; actionType: string }) => { - if (!page.isClosed()) { - return await page.evaluate( - ({ actionType, calNamespace }) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - return window.eventsFiredStoreForPlaywright[`${actionType}-${calNamespace}`]; }, - { actionType, calNamespace } + { calNamespace } ); - } + }, + + async getActionFiredDetails({ calNamespace, actionType }: { calNamespace: string; actionType: string }) { + if (!page.isClosed()) { + return await page.evaluate( + ({ actionType, calNamespace }) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + return window.eventsFiredStoreForPlaywright[`${actionType}-${calNamespace}`]; + }, + { actionType, calNamespace } + ); + } + }, + + async gotoPlayground({ calNamespace, url }: { calNamespace: string; url: string }) { + await this.addEmbedListeners(calNamespace); + await page.goto(url); + }, }; }; diff --git a/apps/web/playwright/lib/fixtures.ts b/apps/web/playwright/lib/fixtures.ts index 3d8fb05490..2c9cb71216 100644 --- a/apps/web/playwright/lib/fixtures.ts +++ b/apps/web/playwright/lib/fixtures.ts @@ -8,7 +8,7 @@ import prisma from "@calcom/prisma"; import type { ExpectedUrlDetails } from "../../../../playwright.config"; import { createBookingsFixture } from "../fixtures/bookings"; -import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds"; +import { createEmbedsFixture } from "../fixtures/embeds"; import { createPaymentsFixture } from "../fixtures/payments"; import { createRoutingFormsFixture } from "../fixtures/routingForms"; import { createServersFixture } from "../fixtures/servers"; @@ -19,8 +19,7 @@ export interface Fixtures { users: ReturnType; bookings: ReturnType; payments: ReturnType; - addEmbedListeners: ReturnType; - getActionFiredDetails: ReturnType; + embeds: ReturnType; servers: ReturnType; prisma: typeof prisma; emails?: API; @@ -36,7 +35,8 @@ declare global { calNamespace: string, // eslint-disable-next-line getActionFiredDetails: (a: { calNamespace: string; actionType: string }) => Promise, - expectedUrlDetails?: ExpectedUrlDetails + expectedUrlDetails?: ExpectedUrlDetails, + isPrendered?: boolean ): Promise; } } @@ -58,14 +58,10 @@ export const test = base.extend({ const payemntsFixture = createPaymentsFixture(page); await use(payemntsFixture); }, - addEmbedListeners: async ({ page }, use) => { + embeds: async ({ page }, use) => { const embedsFixture = createEmbedsFixture(page); await use(embedsFixture); }, - getActionFiredDetails: async ({ page }, use) => { - const getActionFiredDetailsFixture = createGetActionFiredDetails(page); - await use(getActionFiredDetailsFixture); - }, servers: async ({}, use) => { const servers = createServersFixture(); await use(servers); diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index 7279d39d8f..f401dca0f9 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -1,4 +1,4 @@ -import type { Page } from "@playwright/test"; +import type { Frame, Page } from "@playwright/test"; import { expect } from "@playwright/test"; import type { IncomingMessage, ServerResponse } from "http"; import { createServer } from "http"; @@ -86,7 +86,7 @@ export async function waitFor(fn: () => Promise | unknown, opts: { time } } -export async function selectFirstAvailableTimeSlotNextMonth(page: Page) { +export async function selectFirstAvailableTimeSlotNextMonth(page: Page | Frame) { // Let current month dates fully render. await page.click('[data-testid="incrementMonth"]'); diff --git a/packages/embeds/.eslintrc.js b/packages/embeds/.eslintrc.js index 59165894d4..62e45fcb34 100644 --- a/packages/embeds/.eslintrc.js +++ b/packages/embeds/.eslintrc.js @@ -1,3 +1,4 @@ +/** @type {import("eslint").Linter.Config} */ module.exports = { extends: ["../../.eslintrc.js"], rules: { diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html index d62d51ee2a..c4c28ca0ce 100644 --- a/packages/embeds/embed-core/index.html +++ b/packages/embeds/embed-core/index.html @@ -85,20 +85,30 @@ With Dark Color Scheme for the Page Non responsive version of this page here Go to Pre-render test page onlyGo to Prerender test page only + Go to Preload test page only
@@ -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 3/6] 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 4/6] 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 5/6] 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 6/6] feat: overlay your calendar (#11693) * Init header + login modal component * Add calendar settings for authed user * Local storage and using query params for toggle * Toggle connect screen if query param present and no session * Local storage + store + way more than that should be in single commit * Display busy events on weekly view * Confirm booking slot of overlap exists * use chevron right when on column view * Show hover card - overlapping date times * Invalidate on switch * FIx clearing local storage when you login to another account * Force re-render on url state (atom quirks) * Add loading screen * Add dialog close * Remove extra grid config * Translations * [WIP] - tests * fix: google calendar busy times (#11696) Co-authored-by: CarinaWolli * New Crowdin translations by Github Action * fix: rescheduled value DB update on reschedule and insights view cancelleds (#11474) * v3.3.5 * fix minutes string (#11703) Co-authored-by: CarinaWolli * Regenerated yarn.lock * Add error component + loader * await tests * disable tests - add note * Refactor to include selected time * use no-scrollbar * Fix i18n * Fix tablet toolbar * overflow + i18n * Export empty object as test is TODO * Uses booker timezone * Fix hiding switch too early * Handle selected timezone * Fix timezone issues * Fix timezone issues --------- Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: CarinaWolli Co-authored-by: Crowdin Bot Co-authored-by: alannnc Co-authored-by: Alex van Andel Co-authored-by: Peer Richelsen Co-authored-by: Peer Richelsen --- apps/web/playwright/overlay-calendar.e2e.ts | 39 ++++ apps/web/public/static/locales/en/common.json | 4 + .../Booker/components/AvailableTimeSlots.tsx | 2 +- .../bookings/Booker/components/EventMeta.tsx | 9 + .../bookings/Booker/components/Header.tsx | 9 +- .../Booker/components/LargeCalendar.tsx | 27 ++- .../OverlayCalendarContainer.tsx | 154 +++++++++++++ .../OverlayCalendarContinueModal.tsx | 47 ++++ .../OverlayCalendarSettingsModal.tsx | 155 +++++++++++++ .../components/OverlayCalendar/store.ts | 15 ++ .../Booker/components/hooks/useLocalSet.tsx | 64 +++++ packages/features/bookings/Booker/config.ts | 11 + .../bookings/components/AvailableTimes.tsx | 218 ++++++++++++++---- .../lib/useCheckOverlapWithOverlay.tsx | 41 ++++ .../routers/viewer/availability/_router.tsx | 20 +- .../availability/calendarOverlay.handler.ts | 102 ++++++++ .../availability/calendarOverlay.schema.ts | 15 ++ 17 files changed, 877 insertions(+), 55 deletions(-) create mode 100644 apps/web/playwright/overlay-calendar.e2e.ts create mode 100644 packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx create mode 100644 packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContinueModal.tsx create mode 100644 packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx create mode 100644 packages/features/bookings/Booker/components/OverlayCalendar/store.ts create mode 100644 packages/features/bookings/Booker/components/hooks/useLocalSet.tsx create mode 100644 packages/features/bookings/lib/useCheckOverlapWithOverlay.tsx create mode 100644 packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts create mode 100644 packages/trpc/server/routers/viewer/availability/calendarOverlay.schema.ts diff --git a/apps/web/playwright/overlay-calendar.e2e.ts b/apps/web/playwright/overlay-calendar.e2e.ts new file mode 100644 index 0000000000..803f772fb3 --- /dev/null +++ b/apps/web/playwright/overlay-calendar.e2e.ts @@ -0,0 +1,39 @@ +export {}; +// TODO: @sean - I can't run E2E locally - causing me a lot of pain to try and debug. +// Will tackle in follow up once i reset my system. +// test.describe("User can overlay their calendar", async () => { +// test.afterAll(async ({ users }) => { +// await users.deleteAll(); +// }); +// test("Continue with Cal.com flow", async ({ page, users }) => { +// await users.create({ +// username: "overflow-user-test", +// }); +// await test.step("toggles overlay without a session", async () => { +// await page.goto("/overflow-user-test/30-min"); +// const switchLocator = page.locator(`[data-testid=overlay-calendar-switch]`); +// await switchLocator.click(); +// const continueWithCalCom = page.locator(`[data-testid=overlay-calendar-continue-button]`); +// await expect(continueWithCalCom).toBeVisible(); +// await continueWithCalCom.click(); +// }); +// // log in trail user +// await test.step("Log in and return to booking page", async () => { +// const user = await users.create(); +// await user.login(); +// // Expect page to be redirected to the test users booking page +// await page.waitForURL("/overflow-user-test/30-min"); +// }); +// await test.step("Expect settings cog to be visible when session exists", async () => { +// const settingsCog = page.locator(`[data-testid=overlay-calendar-settings-button]`); +// await expect(settingsCog).toBeVisible(); +// }); +// await test.step("Settings should so no calendars connected", async () => { +// const settingsCog = page.locator(`[data-testid=overlay-calendar-settings-button]`); +// await settingsCog.click(); +// await page.waitForLoadState("networkidle"); +// const emptyScreenLocator = page.locator(`[data-testid=empty-screen]`); +// await expect(emptyScreenLocator).toBeVisible(); +// }); +// }); +// }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index db4ff60a2d..b96f728d13 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -268,6 +268,7 @@ "set_availability": "Set your availability", "availability_settings": "Availability Settings", "continue_without_calendar": "Continue without calendar", + "continue_with": "Continue with {{appName}}", "connect_your_calendar": "Connect your calendar", "connect_your_video_app": "Connect your video apps", "connect_your_video_app_instructions": "Connect your video apps to use them on your event types.", @@ -2085,5 +2086,8 @@ "copy_client_secret_info": "After copying the secret you won't be able to view it anymore", "add_new_client": "Add new Client", "this_app_is_not_setup_already": "This app has not been setup yet", + "overlay_my_calendar":"Overlay my calendar", + "overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.", + "view_overlay_calendar_events":"View your calendar events to prevent clashed booking.", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx index 8d34240b8d..f2d40e3654 100644 --- a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx +++ b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx @@ -133,7 +133,7 @@ export const AvailableTimeSlots = ({ : slotsPerDay.length > 0 && slotsPerDay.map((slots) => ( import("@calcom/ui").then((mod) => mod.Time export const EventMeta = () => { const { setTimezone, timeFormat, timezone } = useTimePreferences(); const selectedDuration = useBookerStore((state) => state.selectedDuration); + const setSelectedDuration = useBookerStore((state) => state.setSelectedDuration); const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot); const bookerState = useBookerStore((state) => state.state); const bookingData = useBookerStore((state) => state.bookingData); @@ -36,6 +38,13 @@ export const EventMeta = () => { const isEmbed = useIsEmbed(); const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false; + useEffect(() => { + if (!selectedDuration && event?.length) { + setSelectedDuration(event.length); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [event?.length, selectedDuration]); + if (hideEventTypeDetails) { return null; } diff --git a/packages/features/bookings/Booker/components/Header.tsx b/packages/features/bookings/Booker/components/Header.tsx index 5d65575129..d9942547ad 100644 --- a/packages/features/bookings/Booker/components/Header.tsx +++ b/packages/features/bookings/Booker/components/Header.tsx @@ -11,6 +11,7 @@ import { Calendar, Columns, Grid } from "@calcom/ui/components/icon"; import { TimeFormatToggle } from "../../components/TimeFormatToggle"; import { useBookerStore } from "../store"; import type { BookerLayout } from "../types"; +import { OverlayCalendarContainer } from "./OverlayCalendar/OverlayCalendarContainer"; export function Header({ extraDays, @@ -56,7 +57,12 @@ export function Header({ // In month view we only show the layout toggle. if (isMonthView) { - return ; + return ( +
+ + +
+ ); } const endDate = selectedDate.add(layout === BookerLayouts.COLUMN_VIEW ? extraDays : extraDays - 1, "days"); @@ -113,6 +119,7 @@ export function Header({
+
diff --git a/packages/features/bookings/Booker/components/LargeCalendar.tsx b/packages/features/bookings/Booker/components/LargeCalendar.tsx index 021f53180c..b9684912bc 100644 --- a/packages/features/bookings/Booker/components/LargeCalendar.tsx +++ b/packages/features/bookings/Booker/components/LargeCalendar.tsx @@ -1,20 +1,25 @@ -import { useMemo } from "react"; +import { useMemo, useEffect } from "react"; import dayjs from "@calcom/dayjs"; import { Calendar } from "@calcom/features/calendars/weeklyview"; +import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events"; import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state"; import { useBookerStore } from "../store"; import { useEvent, useScheduleForEvent } from "../utils/event"; +import { getQueryParam } from "../utils/query-param"; +import { useOverlayCalendarStore } from "./OverlayCalendar/store"; export const LargeCalendar = ({ extraDays }: { extraDays: number }) => { const selectedDate = useBookerStore((state) => state.selectedDate); const date = selectedDate || dayjs().format("YYYY-MM-DD"); const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot); const selectedEventDuration = useBookerStore((state) => state.selectedDuration); + const overlayEvents = useOverlayCalendarStore((state) => state.overlayBusyDates); const schedule = useScheduleForEvent({ prefetchNextMonth: !!extraDays && dayjs(date).month() !== dayjs(date).add(extraDays, "day").month(), }); + const displayOverlay = getQueryParam("overlayCalendar") === "true"; const event = useEvent(); const eventDuration = selectedEventDuration || event?.data?.length || 30; @@ -39,6 +44,24 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => { .add(extraDays - 1, "day") .toDate(); + // HACK: force rerender when overlay events change + // Sine we dont use react router here we need to force rerender (ATOM SUPPORT) + // eslint-disable-next-line @typescript-eslint/no-empty-function + useEffect(() => {}, [displayOverlay]); + + const overlayEventsForDate = useMemo(() => { + if (!overlayEvents || !displayOverlay) return []; + return overlayEvents.map((event, id) => { + return { + id, + start: dayjs(event.start).toDate(), + end: dayjs(event.end).toDate(), + title: "Busy", + status: "ACCEPTED", + } as CalendarEvent; + }); + }, [overlayEvents, displayOverlay]); + return (
{ availableTimeslots={availableSlots} startHour={0} endHour={23} - events={[]} + events={overlayEventsForDate} startDate={startDate} endDate={endDate} onEmptyCellClick={(date) => setSelectedTimeslot(date.toISOString())} diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx new file mode 100644 index 0000000000..7603d82795 --- /dev/null +++ b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContainer.tsx @@ -0,0 +1,154 @@ +import { useSession } from "next-auth/react"; +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { useState, useCallback, useEffect } from "react"; + +import dayjs from "@calcom/dayjs"; +import { useTimePreferences } from "@calcom/features/bookings/lib"; +import { classNames } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Button, Switch } from "@calcom/ui"; +import { Settings } from "@calcom/ui/components/icon"; + +import { useBookerStore } from "../../store"; +import { OverlayCalendarContinueModal } from "../OverlayCalendar/OverlayCalendarContinueModal"; +import { OverlayCalendarSettingsModal } from "../OverlayCalendar/OverlayCalendarSettingsModal"; +import { useLocalSet } from "../hooks/useLocalSet"; +import { useOverlayCalendarStore } from "./store"; + +export function OverlayCalendarContainer() { + const { t } = useLocale(); + const [continueWithProvider, setContinueWithProvider] = useState(false); + const [calendarSettingsOverlay, setCalendarSettingsOverlay] = useState(false); + const { data: session } = useSession(); + const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates); + + const layout = useBookerStore((state) => state.layout); + const selectedDate = useBookerStore((state) => state.selectedDate); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { timezone } = useTimePreferences(); + + // Move this to a hook + const { set, clearSet } = useLocalSet<{ + credentialId: number; + externalId: string; + }>("toggledConnectedCalendars", []); + const overlayCalendarQueryParam = searchParams.get("overlayCalendar"); + + const { data: overlayBusyDates } = trpc.viewer.availability.calendarOverlay.useQuery( + { + loggedInUsersTz: timezone || "Europe/London", + dateFrom: selectedDate, + dateTo: selectedDate, + calendarsToLoad: Array.from(set).map((item) => ({ + credentialId: item.credentialId, + externalId: item.externalId, + })), + }, + { + enabled: !!session && set.size > 0 && overlayCalendarQueryParam === "true", + onError: () => { + clearSet(); + }, + } + ); + + useEffect(() => { + if (overlayBusyDates) { + const nowDate = dayjs(); + const usersTimezoneDate = nowDate.tz(timezone); + + const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60; + + const offsettedArray = overlayBusyDates.map((item) => { + return { + ...item, + start: dayjs(item.start).add(offset, "hours").toDate(), + end: dayjs(item.end).add(offset, "hours").toDate(), + }; + }); + setOverlayBusyDates(offsettedArray); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [overlayBusyDates]); + + // Toggle query param for overlay calendar + const toggleOverlayCalendarQueryParam = useCallback( + (state: boolean) => { + const current = new URLSearchParams(Array.from(searchParams.entries())); + if (state) { + current.set("overlayCalendar", "true"); + } else { + current.delete("overlayCalendar"); + } + // cast to string + const value = current.toString(); + const query = value ? `?${value}` : ""; + router.push(`${pathname}${query}`); + }, + [searchParams, pathname, router] + ); + + /** + * If a user is not logged in and the overlay calendar query param is true, + * show the continue modal so they can login / create an account + */ + useEffect(() => { + if (!session && overlayCalendarQueryParam === "true") { + toggleOverlayCalendarQueryParam(false); + setContinueWithProvider(true); + } + }, [session, overlayCalendarQueryParam, toggleOverlayCalendarQueryParam]); + + return ( + <> +
+
+ { + if (!session) { + setContinueWithProvider(state); + } else { + toggleOverlayCalendarQueryParam(state); + } + }} + /> + +
+ {session && ( +
+ { + setContinueWithProvider(val); + }} + /> + { + setCalendarSettingsOverlay(val); + }} + /> + + ); +} diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContinueModal.tsx b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContinueModal.tsx new file mode 100644 index 0000000000..68793fa4a1 --- /dev/null +++ b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarContinueModal.tsx @@ -0,0 +1,47 @@ +import { CalendarSearch } from "lucide-react"; +import { useRouter } from "next/navigation"; + +import { APP_NAME } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button, Dialog, DialogContent, DialogFooter } from "@calcom/ui"; + +interface IOverlayCalendarContinueModalProps { + open?: boolean; + onClose?: (state: boolean) => void; +} + +export function OverlayCalendarContinueModal(props: IOverlayCalendarContinueModalProps) { + const router = useRouter(); + const { t } = useLocale(); + return ( + <> + + +
+ +
+ + {/* Agh modal hacks */} + <> + +
+
+ + ); +} diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx new file mode 100644 index 0000000000..24ccc80a73 --- /dev/null +++ b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx @@ -0,0 +1,155 @@ +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Fragment } from "react"; + +import { classNames } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { + Alert, + Dialog, + DialogContent, + EmptyScreen, + ListItem, + ListItemText, + ListItemTitle, + Switch, + DialogClose, + SkeletonContainer, + SkeletonText, +} from "@calcom/ui"; +import { Calendar } from "@calcom/ui/components/icon"; + +import { useLocalSet } from "../hooks/useLocalSet"; +import { useOverlayCalendarStore } from "./store"; + +interface IOverlayCalendarContinueModalProps { + open?: boolean; + onClose?: (state: boolean) => void; +} + +const SkeletonLoader = () => { + return ( + +
+ + + + +
+
+ ); +}; + +export function OverlayCalendarSettingsModal(props: IOverlayCalendarContinueModalProps) { + const utils = trpc.useContext(); + const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates); + const { data, isLoading } = trpc.viewer.connectedCalendars.useQuery(undefined, { + enabled: !!props.open, + }); + const { toggleValue, hasItem } = useLocalSet<{ + credentialId: number; + externalId: string; + }>("toggledConnectedCalendars", []); + + const router = useRouter(); + const { t } = useLocale(); + return ( + <> + + +
+ {isLoading ? ( + + ) : ( + <> + {data?.connectedCalendars.length === 0 ? ( + router.push("/apps/categories/calendar")} + /> + ) : ( + <> + {data?.connectedCalendars.map((item) => ( + + {item.error && !item.calendars && ( + + )} + {item?.error === undefined && item.calendars && ( + +
+ { + // eslint-disable-next-line @next/next/no-img-element + item.integration.logo && ( + {item.integration.title} + ) + } +
+ + + {item.integration.name || item.integration.title} + + + {item.primary.email} +
+
+
+
    + {item.calendars.map((cal, index) => { + const id = cal.integrationTitle ?? `calendar-switch-${index}`; + return ( +
  • + { + toggleValue({ + credentialId: item.credentialId, + externalId: cal.externalId, + }); + setOverlayBusyDates([]); + utils.viewer.availability.calendarOverlay.reset(); + }} + /> + +
  • + ); + })} +
+
+
+ )} +
+ ))} + + )} + + )} +
+ +
+ {t("done")} +
+
+
+ + ); +} diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/store.ts b/packages/features/bookings/Booker/components/OverlayCalendar/store.ts new file mode 100644 index 0000000000..1d9fd90b55 --- /dev/null +++ b/packages/features/bookings/Booker/components/OverlayCalendar/store.ts @@ -0,0 +1,15 @@ +import { create } from "zustand"; + +import type { EventBusyDate } from "@calcom/types/Calendar"; + +interface IOverlayCalendarStore { + overlayBusyDates: EventBusyDate[] | undefined; + setOverlayBusyDates: (busyDates: EventBusyDate[]) => void; +} + +export const useOverlayCalendarStore = create((set) => ({ + overlayBusyDates: undefined, + setOverlayBusyDates: (busyDates: EventBusyDate[]) => { + set({ overlayBusyDates: busyDates }); + }, +})); diff --git a/packages/features/bookings/Booker/components/hooks/useLocalSet.tsx b/packages/features/bookings/Booker/components/hooks/useLocalSet.tsx new file mode 100644 index 0000000000..3bcc9dad14 --- /dev/null +++ b/packages/features/bookings/Booker/components/hooks/useLocalSet.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; + +export interface HasExternalId { + externalId: string; +} + +export function useLocalSet(key: string, initialValue: T[]) { + const [set, setSet] = useState>(() => { + const storedValue = localStorage.getItem(key); + return storedValue ? new Set(JSON.parse(storedValue)) : new Set(initialValue); + }); + + useEffect(() => { + localStorage.setItem(key, JSON.stringify(Array.from(set))); + }, [key, set]); + + const addValue = (value: T) => { + setSet((prevSet) => new Set(prevSet).add(value)); + }; + + const removeById = (id: string) => { + setSet((prevSet) => { + const updatedSet = new Set(prevSet); + updatedSet.forEach((item) => { + if (item.externalId === id) { + updatedSet.delete(item); + } + }); + return updatedSet; + }); + }; + + const toggleValue = (value: T) => { + setSet((prevSet) => { + const updatedSet = new Set(prevSet); + let itemFound = false; + + updatedSet.forEach((item) => { + if (item.externalId === value.externalId) { + itemFound = true; + updatedSet.delete(item); + } + }); + + if (!itemFound) { + updatedSet.add(value); + } + + return updatedSet; + }); + }; + + const hasItem = (value: T) => { + return Array.from(set).some((item) => item.externalId === value.externalId); + }; + + const clearSet = () => { + setSet(() => new Set()); + // clear local storage too + localStorage.removeItem(key); + }; + + return { set, addValue, removeById, toggleValue, hasItem, clearSet }; +} diff --git a/packages/features/bookings/Booker/config.ts b/packages/features/bookings/Booker/config.ts index b3f537284f..516db66c94 100644 --- a/packages/features/bookings/Booker/config.ts +++ b/packages/features/bookings/Booker/config.ts @@ -28,6 +28,17 @@ export const fadeInUp = { transition: { ease: "easeInOut", delay: 0.1 }, }; +export const fadeInRight = { + variants: { + visible: { opacity: 1, x: 0 }, + hidden: { opacity: 0, x: -20 }, + }, + initial: "hidden", + exit: "hidden", + animate: "visible", + transition: { ease: "easeInOut", delay: 0.1 }, +}; + type ResizeAnimationConfig = { [key in BookerLayout]: { [key in BookerState | "default"]?: React.CSSProperties; diff --git a/packages/features/bookings/components/AvailableTimes.tsx b/packages/features/bookings/components/AvailableTimes.tsx index 29bc587255..e46e020a6e 100644 --- a/packages/features/bookings/components/AvailableTimes.tsx +++ b/packages/features/bookings/components/AvailableTimes.tsx @@ -1,4 +1,8 @@ -import { CalendarX2 } from "lucide-react"; +// We do not need to worry about importing framer-motion here as it is lazy imported in Booker. +import * as HoverCard from "@radix-ui/react-hover-card"; +import { AnimatePresence, m } from "framer-motion"; +import { CalendarX2, ChevronRight } from "lucide-react"; +import { useCallback, useState } from "react"; import dayjs from "@calcom/dayjs"; import type { Slots } from "@calcom/features/schedules"; @@ -7,17 +11,21 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Button, SkeletonText } from "@calcom/ui"; import { useBookerStore } from "../Booker/store"; +import { getQueryParam } from "../Booker/utils/query-param"; import { useTimePreferences } from "../lib"; +import { useCheckOverlapWithOverlay } from "../lib/useCheckOverlapWithOverlay"; import { SeatsAvailabilityText } from "./SeatsAvailabilityText"; +type TOnTimeSelect = ( + time: string, + attendees: number, + seatsPerTimeSlot?: number | null, + bookingUid?: string +) => void; + type AvailableTimesProps = { slots: Slots[string]; - onTimeSelect: ( - time: string, - attendees: number, - seatsPerTimeSlot?: number | null, - bookingUid?: string - ) => void; + onTimeSelect: TOnTimeSelect; seatsPerTimeSlot?: number | null; showAvailableSeatsCount?: boolean | null; showTimeFormatToggle?: boolean; @@ -25,6 +33,148 @@ type AvailableTimesProps = { selectedSlots?: string[]; }; +const SlotItem = ({ + slot, + seatsPerTimeSlot, + selectedSlots, + onTimeSelect, + showAvailableSeatsCount, +}: { + slot: Slots[string][number]; + seatsPerTimeSlot?: number | null; + selectedSlots?: string[]; + onTimeSelect: TOnTimeSelect; + showAvailableSeatsCount?: boolean | null; +}) => { + const { t } = useLocale(); + + const overlayCalendarToggled = getQueryParam("overlayCalendar") === "true"; + const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]); + const selectedDuration = useBookerStore((state) => state.selectedDuration); + const bookingData = useBookerStore((state) => state.bookingData); + const layout = useBookerStore((state) => state.layout); + const hasTimeSlots = !!seatsPerTimeSlot; + const computedDateWithUsersTimezone = dayjs.utc(slot.time).tz(timezone); + + const bookingFull = !!(hasTimeSlots && slot.attendees && slot.attendees >= seatsPerTimeSlot); + const isHalfFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5; + const isNearlyFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83; + const colorClass = isNearlyFull ? "bg-rose-600" : isHalfFull ? "bg-yellow-500" : "bg-emerald-400"; + + const nowDate = dayjs(); + const usersTimezoneDate = nowDate.tz(timezone); + + const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60; + + const { isOverlapping, overlappingTimeEnd, overlappingTimeStart } = useCheckOverlapWithOverlay( + computedDateWithUsersTimezone, + selectedDuration, + offset + ); + const [overlapConfirm, setOverlapConfirm] = useState(false); + + const onButtonClick = useCallback(() => { + if (!overlayCalendarToggled) { + onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid); + return; + } + if (isOverlapping && overlapConfirm) { + setOverlapConfirm(false); + return; + } + + if (isOverlapping && !overlapConfirm) { + setOverlapConfirm(true); + return; + } + if (!overlapConfirm) { + onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid); + } + }, [ + overlayCalendarToggled, + isOverlapping, + overlapConfirm, + onTimeSelect, + slot.time, + slot?.attendees, + slot.bookingUid, + seatsPerTimeSlot, + ]); + + return ( + +
+ + {overlapConfirm && isOverlapping && ( + + + + + + + + +
+
+

Busy

+
+

+ {overlappingTimeStart} - {overlappingTimeEnd} +

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

)} - - {slots.map((slot) => { - const bookingFull = !!(hasTimeSlots && slot.attendees && slot.attendees >= seatsPerTimeSlot); - const isHalfFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5; - const isNearlyFull = - slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83; - - const colorClass = isNearlyFull ? "bg-rose-600" : isHalfFull ? "bg-yellow-500" : "bg-emerald-400"; - return ( - - ); - })} + {slots.map((slot) => ( + + ))}
); diff --git a/packages/features/bookings/lib/useCheckOverlapWithOverlay.tsx b/packages/features/bookings/lib/useCheckOverlapWithOverlay.tsx new file mode 100644 index 0000000000..a1a3020da8 --- /dev/null +++ b/packages/features/bookings/lib/useCheckOverlapWithOverlay.tsx @@ -0,0 +1,41 @@ +import type { Dayjs } from "@calcom/dayjs"; +import dayjs from "@calcom/dayjs"; + +import { useOverlayCalendarStore } from "../Booker/components/OverlayCalendar/store"; + +function getCurrentTime(date: Date) { + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); + return `${hours}:${minutes}`; +} + +export function useCheckOverlapWithOverlay(start: Dayjs, selectedDuration: number | null, offset: number) { + const overlayBusyDates = useOverlayCalendarStore((state) => state.overlayBusyDates); + + let overlappingTimeStart: string | null = null; + let overlappingTimeEnd: string | null = null; + + const isOverlapping = + overlayBusyDates && + overlayBusyDates.some((busyDate) => { + const busyDateStart = dayjs(busyDate.start); + const busyDateEnd = dayjs(busyDate.end); + const selectedEndTime = dayjs(start.add(offset, "hours")).add(selectedDuration ?? 0, "minute"); + + const isOverlapping = + (selectedEndTime.isSame(busyDateStart) || selectedEndTime.isAfter(busyDateStart)) && + start.add(offset, "hours") < busyDateEnd && + selectedEndTime > busyDateStart; + + overlappingTimeStart = isOverlapping ? getCurrentTime(busyDateStart.toDate()) : null; + overlappingTimeEnd = isOverlapping ? getCurrentTime(busyDateEnd.toDate()) : null; + + return isOverlapping; + }); + + return { isOverlapping, overlappingTimeStart, overlappingTimeEnd } as { + isOverlapping: boolean; + overlappingTimeStart: string | null; + overlappingTimeEnd: string | null; + }; +} diff --git a/packages/trpc/server/routers/viewer/availability/_router.tsx b/packages/trpc/server/routers/viewer/availability/_router.tsx index 1084dc5dc7..12a2fbcfb0 100644 --- a/packages/trpc/server/routers/viewer/availability/_router.tsx +++ b/packages/trpc/server/routers/viewer/availability/_router.tsx @@ -1,5 +1,6 @@ import authedProcedure from "../../../procedures/authedProcedure"; import { router } from "../../../trpc"; +import { ZCalendarOverlayInputSchema } from "./calendarOverlay.schema"; import { scheduleRouter } from "./schedule/_router"; import { ZListTeamAvailaiblityScheme } from "./team/listTeamAvailability.schema"; import { ZUserInputSchema } from "./user.schema"; @@ -7,6 +8,7 @@ import { ZUserInputSchema } from "./user.schema"; type AvailabilityRouterHandlerCache = { list?: typeof import("./list.handler").listHandler; user?: typeof import("./user.handler").userHandler; + calendarOverlay?: typeof import("./calendarOverlay.handler").calendarOverlayHandler; listTeamAvailability?: typeof import("./team/listTeamAvailability.handler").listTeamAvailabilityHandler; }; @@ -60,6 +62,22 @@ export const availabilityRouter = router({ input, }); }), - schedule: scheduleRouter, + calendarOverlay: authedProcedure.input(ZCalendarOverlayInputSchema).query(async ({ ctx, input }) => { + if (!UNSTABLE_HANDLER_CACHE.calendarOverlay) { + UNSTABLE_HANDLER_CACHE.calendarOverlay = await import("./calendarOverlay.handler").then( + (mod) => mod.calendarOverlayHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.calendarOverlay) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.calendarOverlay({ + ctx, + input, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts b/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts new file mode 100644 index 0000000000..3ac4cc8581 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts @@ -0,0 +1,102 @@ +import { getBusyCalendarTimes } from "@calcom/core/CalendarManager"; +import dayjs from "@calcom/dayjs"; +import type { EventBusyDate } from "@calcom/types/Calendar"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; +import type { TCalendarOverlayInputSchema } from "./calendarOverlay.schema"; + +type ListOptions = { + ctx: { + user: NonNullable; + }; + input: TCalendarOverlayInputSchema; +}; + +export const calendarOverlayHandler = async ({ ctx, input }: ListOptions) => { + const { user } = ctx; + const { calendarsToLoad, dateFrom, dateTo } = input; + + if (!dateFrom || !dateTo) { + return [] as EventBusyDate[]; + } + + // get all unique credentialIds from calendarsToLoad + const uniqueCredentialIds = Array.from(new Set(calendarsToLoad.map((item) => item.credentialId))); + + // To call getCalendar we need + + // Ensure that the user has access to all of the credentialIds + const credentials = await prisma.credential.findMany({ + where: { + id: { + in: uniqueCredentialIds, + }, + userId: user.id, + }, + select: { + id: true, + type: true, + key: true, + userId: true, + teamId: true, + appId: true, + invalid: true, + user: { + select: { + email: true, + }, + }, + }, + }); + + if (credentials.length !== uniqueCredentialIds.length) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Unauthorized - These credentials do not belong to you", + }); + } + + const composedSelectedCalendars = calendarsToLoad.map((calendar) => { + const credential = credentials.find((item) => item.id === calendar.credentialId); + if (!credential) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Unauthorized - These credentials do not belong to you", + }); + } + return { + ...calendar, + userId: user.id, + integration: credential.type, + }; + }); + + // get all clanedar services + const calendarBusyTimes = await getBusyCalendarTimes( + "", + credentials, + dateFrom, + dateTo, + composedSelectedCalendars + ); + + // Convert to users timezone + + const userTimeZone = input.loggedInUsersTz; + const calendarBusyTimesConverted = calendarBusyTimes.map((busyTime) => { + const busyTimeStart = dayjs(busyTime.start); + const busyTimeEnd = dayjs(busyTime.end); + const busyTimeStartDate = busyTimeStart.tz(userTimeZone).toDate(); + const busyTimeEndDate = busyTimeEnd.tz(userTimeZone).toDate(); + + return { + ...busyTime, + start: busyTimeStartDate, + end: busyTimeEndDate, + } as EventBusyDate; + }); + + return calendarBusyTimesConverted; +}; diff --git a/packages/trpc/server/routers/viewer/availability/calendarOverlay.schema.ts b/packages/trpc/server/routers/viewer/availability/calendarOverlay.schema.ts new file mode 100644 index 0000000000..c424ef3bf0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/calendarOverlay.schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const ZCalendarOverlayInputSchema = z.object({ + loggedInUsersTz: z.string(), + dateFrom: z.string().nullable(), + dateTo: z.string().nullable(), + calendarsToLoad: z.array( + z.object({ + credentialId: z.number(), + externalId: z.string(), + }) + ), +}); + +export type TCalendarOverlayInputSchema = z.infer;