diff --git a/apps/web/playwright/embed-code-generator.e2e.ts b/apps/web/playwright/embed-code-generator.e2e.ts index f49b853259..dec2200db9 100644 --- a/apps/web/playwright/embed-code-generator.e2e.ts +++ b/apps/web/playwright/embed-code-generator.e2e.ts @@ -1,19 +1,337 @@ import type { Page } from "@playwright/test"; import { expect } from "@playwright/test"; +import { Linter } from "eslint"; +import { parse } from "node-html-parser"; +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import { EMBED_LIB_URL, WEBAPP_URL } from "@calcom/lib/constants"; +import { MembershipRole } from "@calcom/prisma/client"; import { test } from "./lib/fixtures"; -function chooseEmbedType(page: Page, embedType: string) { +const linter = new Linter(); +const eslintRules = { + "no-undef": "error", + "no-unused-vars": "off", +} as const; +test.describe.configure({ mode: "parallel" }); + +test.afterEach(({ users }) => users.deleteAll()); + +test.describe("Embed Code Generator Tests", () => { + test.describe("Non-Organization", () => { + test.beforeEach(async ({ users }) => { + const pro = await users.create(); + await pro.apiLogin(); + }); + + test.describe("Event Types Page", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/event-types"); + }); + + test("open Embed Dialog and choose Inline for First Event Type", async ({ page, users }) => { + const [pro] = users.get(); + const embedUrl = await clickFirstEventTypeEmbedButton(page); + await expectToBeNavigatingToEmbedTypesDialog(page, { + embedUrl, + basePage: "/event-types", + }); + + chooseEmbedType(page, "inline"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + embedUrl, + embedType: "inline", + basePage: "/event-types", + }); + + await expectToContainValidCode(page, { + language: "html", + embedType: "inline", + orgSlug: null, + }); + + await goToReactCodeTab(page); + await expectToContainValidCode(page, { + language: "react", + embedType: "inline", + orgSlug: null, + }); + + await goToPreviewTab(page); + + await expectToContainValidPreviewIframe(page, { + embedType: "inline", + calLink: `${pro.username}/30-min`, + }); + }); + + test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page, users }) => { + const [pro] = users.get(); + const embedUrl = await clickFirstEventTypeEmbedButton(page); + + await expectToBeNavigatingToEmbedTypesDialog(page, { + embedUrl, + basePage: "/event-types", + }); + + chooseEmbedType(page, "floating-popup"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + embedUrl, + embedType: "floating-popup", + basePage: "/event-types", + }); + await expectToContainValidCode(page, { + language: "html", + embedType: "floating-popup", + orgSlug: null, + }); + + await goToReactCodeTab(page); + await expectToContainValidCode(page, { + language: "react", + embedType: "floating-popup", + orgSlug: null, + }); + + await goToPreviewTab(page); + await expectToContainValidPreviewIframe(page, { + embedType: "floating-popup", + calLink: `${pro.username}/30-min`, + }); + }); + + test("open Embed Dialog and choose element-click for First Event Type", async ({ page, users }) => { + const [pro] = users.get(); + const embedUrl = await clickFirstEventTypeEmbedButton(page); + + await expectToBeNavigatingToEmbedTypesDialog(page, { + embedUrl, + basePage: "/event-types", + }); + + chooseEmbedType(page, "element-click"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + embedUrl, + embedType: "element-click", + basePage: "/event-types", + }); + await expectToContainValidCode(page, { + language: "html", + embedType: "element-click", + orgSlug: null, + }); + + await goToReactCodeTab(page); + await expectToContainValidCode(page, { + language: "react", + embedType: "element-click", + orgSlug: null, + }); + + await goToPreviewTab(page); + await expectToContainValidPreviewIframe(page, { + embedType: "element-click", + calLink: `${pro.username}/30-min`, + }); + }); + }); + test.describe("Event Type Edit Page", () => { + test.beforeEach(async ({ page }) => { + await page.goto(`/event-types`); + await Promise.all([ + page.locator('a[href*="/event-types/"]').first().click(), + page.waitForURL((url) => url.pathname.startsWith("/event-types/")), + ]); + }); + + test("open Embed Dialog for the Event Type", async ({ page }) => { + const basePage = new URL(page.url()).pathname; + const embedUrl = await clickEmbedButton(page); + await expectToBeNavigatingToEmbedTypesDialog(page, { + embedUrl, + basePage, + }); + + chooseEmbedType(page, "inline"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + embedUrl, + basePage, + embedType: "inline", + }); + + await expectToContainValidCode(page, { + language: "html", + embedType: "inline", + orgSlug: null, + }); + + await goToPreviewTab(page); + + await expectToContainValidPreviewIframe(page, { + embedType: "inline", + calLink: decodeURIComponent(embedUrl), + }); + }); + }); + }); + + test.describe("Organization", () => { + test.beforeEach(async ({ users, orgs }) => { + const org = await orgs.create({ + name: "TestOrg", + }); + const user = await users.create({ + organizationId: org.id, + roleInOrganization: MembershipRole.MEMBER, + }); + await user.apiLogin(); + }); + test.describe("Event Types Page", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/event-types"); + }); + + test("open Embed Dialog and choose Inline for First Event Type", async ({ page, users }) => { + const [user] = users.get(); + const { team: org } = await user.getOrgMembership(); + const embedUrl = await clickFirstEventTypeEmbedButton(page); + await expectToBeNavigatingToEmbedTypesDialog(page, { + embedUrl, + basePage: "/event-types", + }); + + chooseEmbedType(page, "inline"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + embedUrl, + embedType: "inline", + basePage: "/event-types", + }); + + // Default tab is HTML code tab + await expectToContainValidCode(page, { + language: "html", + embedType: "inline", + orgSlug: org.slug, + }); + + await goToReactCodeTab(page); + await expectToContainValidCode(page, { + language: "react", + embedType: "inline", + orgSlug: org.slug, + }); + + await goToPreviewTab(page); + await expectToContainValidPreviewIframe(page, { + embedType: "inline", + calLink: `${user.username}/30-min`, + bookerUrl: getOrgFullOrigin(org?.slug ?? ""), + }); + }); + + test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page, users }) => { + const [user] = users.get(); + const { team: org } = await user.getOrgMembership(); + + const embedUrl = await clickFirstEventTypeEmbedButton(page); + + await expectToBeNavigatingToEmbedTypesDialog(page, { + embedUrl, + basePage: "/event-types", + }); + + chooseEmbedType(page, "floating-popup"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + embedUrl, + embedType: "floating-popup", + basePage: "/event-types", + }); + await expectToContainValidCode(page, { + language: "html", + embedType: "floating-popup", + orgSlug: org.slug, + }); + + await goToReactCodeTab(page); + await expectToContainValidCode(page, { + language: "react", + embedType: "floating-popup", + orgSlug: org.slug, + }); + + await goToPreviewTab(page); + await expectToContainValidPreviewIframe(page, { + embedType: "floating-popup", + calLink: `${user.username}/30-min`, + bookerUrl: getOrgFullOrigin(org?.slug ?? ""), + }); + }); + + test("open Embed Dialog and choose element-click for First Event Type", async ({ page, users }) => { + const [user] = users.get(); + const embedUrl = await clickFirstEventTypeEmbedButton(page); + const { team: org } = await user.getOrgMembership(); + + await expectToBeNavigatingToEmbedTypesDialog(page, { + embedUrl, + basePage: "/event-types", + }); + + chooseEmbedType(page, "element-click"); + + await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { + embedUrl, + embedType: "element-click", + basePage: "/event-types", + }); + await expectToContainValidCode(page, { + language: "html", + embedType: "element-click", + orgSlug: org.slug, + }); + + await goToReactCodeTab(page); + await expectToContainValidCode(page, { + language: "react", + embedType: "element-click", + orgSlug: org.slug, + }); + + await goToPreviewTab(page); + await expectToContainValidPreviewIframe(page, { + embedType: "element-click", + calLink: `${user.username}/30-min`, + bookerUrl: getOrgFullOrigin(org?.slug ?? ""), + }); + }); + }); + }); +}); + +type EmbedType = "inline" | "floating-popup" | "element-click"; +function chooseEmbedType(page: Page, embedType: EmbedType) { page.locator(`[data-testid=${embedType}]`).click(); } -async function gotToPreviewTab(page: Page) { +async function goToPreviewTab(page: Page) { // To prevent early timeouts // eslint-disable-next-line playwright/no-wait-for-timeout await page.waitForTimeout(1000); - await page.locator("[data-testid=embed-tabs]").locator("text=Preview").click(); + await page.locator("[data-testid=horizontal-tab-Preview]").click(); +} + +async function goToReactCodeTab(page: Page) { + // To prevent early timeouts + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1000); + await page.locator("[data-testid=horizontal-tab-React]").click(); } async function clickEmbedButton(page: Page) { @@ -55,7 +373,7 @@ async function expectToBeNavigatingToEmbedCodeAndPreviewDialog( basePage, }: { embedUrl: string | null; - embedType: string; + embedType: EmbedType; basePage: string; } ) { @@ -73,10 +391,108 @@ async function expectToBeNavigatingToEmbedCodeAndPreviewDialog( }); } -async function expectToContainValidCode(page: Page, { embedType }: { embedType: string }) { +async function expectToContainValidCode( + page: Page, + { + embedType, + language, + orgSlug, + }: { embedType: EmbedType; language: "html" | "react"; orgSlug: string | null } +) { + if (language === "react") { + return expectValidReactEmbedSnippet(page, { embedType, orgSlug }); + } + if (language === "html") { + return expectValidHtmlEmbedSnippet(page, { embedType, orgSlug }); + } + throw new Error("Unknown language"); +} + +async function expectValidHtmlEmbedSnippet( + page: Page, + { embedType, orgSlug }: { embedType: EmbedType; orgSlug: string | null } +) { const embedCode = await page.locator("[data-testid=embed-code]").inputValue(); - expect(embedCode.includes("(function (C, A, L)")).toBe(true); - expect(embedCode.includes(`Cal ${embedType} embed code begins`)).toBe(true); + expect(embedCode).toContain("function (C, A, L)"); + expect(embedCode).toContain(`Cal ${embedType} embed code begins`); + if (orgSlug) { + expect(embedCode).toContain(orgSlug); + } + + const dom = parse(embedCode); + const scripts = dom.getElementsByTagName("script"); + assertThatCodeIsValidVanillaJsCode(scripts[0].innerText); + + return { + message: () => `passed`, + pass: true, + }; +} + +function assertThatCodeIsValidVanillaJsCode(code: string) { + const lintResult = linter.verify(code, { + env: { + browser: true, + }, + parserOptions: { + ecmaVersion: 2021, + }, + globals: { + Cal: "readonly", + }, + rules: eslintRules, + }); + if (lintResult.length) { + console.log( + JSON.stringify({ + lintResult, + code, + }) + ); + } + expect(lintResult.length).toBe(0); +} + +function assertThatCodeIsValidReactCode(code: string) { + const lintResult = linter.verify(code, { + env: { + browser: true, + }, + parserOptions: { + ecmaVersion: 2021, + ecmaFeatures: { + jsx: true, + }, + sourceType: "module", + }, + rules: eslintRules, + }); + if (lintResult.length) { + console.log( + JSON.stringify({ + lintResult, + code, + }) + ); + } + expect(lintResult.length).toBe(0); +} + +async function expectValidReactEmbedSnippet( + page: Page, + { embedType, orgSlug }: { embedType: EmbedType; orgSlug: string | null } +) { + const embedCode = await page.locator("[data-testid=embed-react]").inputValue(); + expect(embedCode).toContain("export default function MyApp("); + expect(embedCode).toContain( + embedType === "floating-popup" ? "floatingButton" : embedType === "inline" ? ` `passed`, pass: true, @@ -88,141 +504,10 @@ async function expectToContainValidCode(page: Page, { embedType }: { embedType: */ async function expectToContainValidPreviewIframe( page: Page, - { embedType, calLink }: { embedType: string; calLink: string } + { embedType, calLink, bookerUrl }: { embedType: EmbedType; calLink: string; bookerUrl?: string } ) { - const bookerUrl = `${WEBAPP_URL}`; + bookerUrl = bookerUrl || `${WEBAPP_URL}`; expect(await page.locator("[data-testid=embed-preview]").getAttribute("src")).toContain( `/preview.html?embedType=${embedType}&calLink=${calLink}&embedLibUrl=${EMBED_LIB_URL}&bookerUrl=${bookerUrl}` ); } - -test.describe.configure({ mode: "parallel" }); - -test.afterEach(({ users }) => users.deleteAll()); - -test.describe("Embed Code Generator Tests", () => { - test.beforeEach(async ({ users }) => { - const pro = await users.create(); - await pro.apiLogin(); - }); - - test.describe("Event Types Page", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/event-types"); - }); - - test("open Embed Dialog and choose Inline for First Event Type", async ({ page, users }) => { - const [pro] = users.get(); - const embedUrl = await clickFirstEventTypeEmbedButton(page); - await expectToBeNavigatingToEmbedTypesDialog(page, { - embedUrl, - basePage: "/event-types", - }); - - chooseEmbedType(page, "inline"); - - await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { - embedUrl, - embedType: "inline", - basePage: "/event-types", - }); - - await expectToContainValidCode(page, { embedType: "inline" }); - - await gotToPreviewTab(page); - - await expectToContainValidPreviewIframe(page, { - embedType: "inline", - calLink: `${pro.username}/30-min`, - }); - }); - - test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page, users }) => { - const [pro] = users.get(); - const embedUrl = await clickFirstEventTypeEmbedButton(page); - - await expectToBeNavigatingToEmbedTypesDialog(page, { - embedUrl, - basePage: "/event-types", - }); - - chooseEmbedType(page, "floating-popup"); - - await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { - embedUrl, - embedType: "floating-popup", - basePage: "/event-types", - }); - await expectToContainValidCode(page, { embedType: "floating-popup" }); - - await gotToPreviewTab(page); - await expectToContainValidPreviewIframe(page, { - embedType: "floating-popup", - calLink: `${pro.username}/30-min`, - }); - }); - - test("open Embed Dialog and choose element-click for First Event Type", async ({ page, users }) => { - const [pro] = users.get(); - const embedUrl = await clickFirstEventTypeEmbedButton(page); - - await expectToBeNavigatingToEmbedTypesDialog(page, { - embedUrl, - basePage: "/event-types", - }); - - chooseEmbedType(page, "element-click"); - - await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { - embedUrl, - embedType: "element-click", - basePage: "/event-types", - }); - await expectToContainValidCode(page, { embedType: "element-click" }); - - await gotToPreviewTab(page); - await expectToContainValidPreviewIframe(page, { - embedType: "element-click", - calLink: `${pro.username}/30-min`, - }); - }); - }); - - test.describe("Event Type Edit Page", () => { - test.beforeEach(async ({ page }) => { - await page.goto(`/event-types`); - await Promise.all([ - page.locator('a[href*="/event-types/"]').first().click(), - page.waitForURL((url) => url.pathname.startsWith("/event-types/")), - ]); - }); - - test("open Embed Dialog for the Event Type", async ({ page }) => { - const basePage = new URL(page.url()).pathname; - const embedUrl = await clickEmbedButton(page); - await expectToBeNavigatingToEmbedTypesDialog(page, { - embedUrl, - basePage, - }); - - chooseEmbedType(page, "inline"); - - await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, { - embedUrl, - basePage, - embedType: "inline", - }); - - await expectToContainValidCode(page, { - embedType: "inline", - }); - - await gotToPreviewTab(page); - - await expectToContainValidPreviewIframe(page, { - embedType: "inline", - calLink: decodeURIComponent(embedUrl), - }); - }); - }); -}); diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index b0d0a48c65..f779f9a773 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -460,7 +460,7 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { } return membership; }, - getOrg: async () => { + getOrgMembership: async () => { return prisma.membership.findFirstOrThrow({ where: { userId: user.id, @@ -471,7 +471,13 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { }, }, }, - include: { team: { select: { children: true, metadata: true, name: true } } }, + include: { + team: { + include: { + children: true, + }, + }, + }, }); }, getFirstEventAsOwner: async () => diff --git a/apps/web/playwright/organization/organization-invitation.e2e.ts b/apps/web/playwright/organization/organization-invitation.e2e.ts index 6561a01e55..58ae4120c8 100644 --- a/apps/web/playwright/organization/organization-invitation.e2e.ts +++ b/apps/web/playwright/organization/organization-invitation.e2e.ts @@ -14,7 +14,7 @@ test.afterEach(async ({ users, emails, clipboard }) => { test.describe("Organization", () => { test("Invitation (non verified)", async ({ browser, page, users, emails, clipboard }) => { const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true }); - const { team: org } = await orgOwner.getOrg(); + const { team: org } = await orgOwner.getOrgMembership(); await orgOwner.apiLogin(); await page.goto("/settings/organizations/members"); await page.waitForLoadState("networkidle"); @@ -92,7 +92,7 @@ test.describe("Organization", () => { test("Invitation (verified)", async ({ browser, page, users, emails }) => { const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, isOrgVerified: true }); - const { team: org } = await orgOwner.getOrg(); + const { team: org } = await orgOwner.getOrgMembership(); await orgOwner.apiLogin(); await page.goto("/settings/organizations/members"); await page.waitForLoadState("networkidle"); diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts index ae0c11d821..ba9f2960d6 100644 --- a/apps/web/playwright/teams.e2e.ts +++ b/apps/web/playwright/teams.e2e.ts @@ -168,7 +168,7 @@ test.describe("Teams - NonOrg", () => { await expect(page.locator("[data-testid=alert]")).toBeVisible(); // cleanup - const org = await owner.getOrg(); + const org = await owner.getOrgMembership(); await prisma.team.delete({ where: { id: org.teamId } }); } }); diff --git a/apps/web/playwright/unpublished.e2e.ts b/apps/web/playwright/unpublished.e2e.ts index 5f5a66b9eb..8ae45b8038 100644 --- a/apps/web/playwright/unpublished.e2e.ts +++ b/apps/web/playwright/unpublished.e2e.ts @@ -45,7 +45,7 @@ test.describe("Unpublished", () => { test("Organization profile", async ({ users, page }) => { const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true, isOrg: true }); - const { team: org } = await owner.getOrg(); + const { team: org } = await owner.getOrgMembership(); const { requestedSlug } = org.metadata as { requestedSlug: string }; await page.goto(`/org/${requestedSlug}`); await page.waitForLoadState("networkidle"); @@ -62,7 +62,7 @@ test.describe("Unpublished", () => { isOrg: true, hasSubteam: true, }); - const { team: org } = await owner.getOrg(); + const { team: org } = await owner.getOrgMembership(); const { requestedSlug } = org.metadata as { requestedSlug: string }; const [{ slug: subteamSlug }] = org.children as { slug: string }[]; await page.goto(`/org/${requestedSlug}/team/${subteamSlug}`); @@ -80,7 +80,7 @@ test.describe("Unpublished", () => { isOrg: true, hasSubteam: true, }); - const { team: org } = await owner.getOrg(); + const { team: org } = await owner.getOrgMembership(); const { requestedSlug } = org.metadata as { requestedSlug: string }; const [{ slug: subteamSlug, id: subteamId }] = org.children as { slug: string; id: number }[]; const { slug: subteamEventSlug } = await owner.getFirstTeamEvent(subteamId); @@ -95,7 +95,7 @@ test.describe("Unpublished", () => { test("Organization user", async ({ users, page }) => { const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true, isOrg: true }); - const { team: org } = await owner.getOrg(); + const { team: org } = await owner.getOrgMembership(); const { requestedSlug } = org.metadata as { requestedSlug: string }; await page.goto(`/org/${requestedSlug}/${owner.username}`); await page.waitForLoadState("networkidle"); @@ -107,7 +107,7 @@ test.describe("Unpublished", () => { test("Organization user event-type", async ({ users, page }) => { const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true, isOrg: true }); - const { team: org } = await owner.getOrg(); + const { team: org } = await owner.getOrgMembership(); const { requestedSlug } = org.metadata as { requestedSlug: string }; const [{ slug: ownerEventType }] = owner.eventTypes; await page.goto(`/org/${requestedSlug}/${owner.username}/${ownerEventType}`); diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 6f234e844e..5db6e68c4c 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -547,6 +547,45 @@ export function expectCalendarEventCreationFailureEmails({ ); } +export function expectSuccessfulRoudRobinReschedulingEmails({ + emails, + newOrganizer, + prevOrganizer, +}: { + emails: Fixtures["emails"]; + newOrganizer: { email: string; name: string }; + prevOrganizer: { email: string; name: string }; +}) { + if (newOrganizer !== prevOrganizer) { + // new organizer should recieve scheduling emails + expect(emails).toHaveEmail( + { + heading: "new_event_scheduled", + to: `${newOrganizer.email}`, + }, + `${newOrganizer.email}` + ); + + // old organizer should recieve cancelled emails + expect(emails).toHaveEmail( + { + heading: "event_request_cancelled", + to: `${prevOrganizer.email}`, + }, + `${prevOrganizer.email}` + ); + } else { + // organizer should recieve rescheduled emails + expect(emails).toHaveEmail( + { + heading: "event_has_been_rescheduled", + to: `${newOrganizer.email}`, + }, + `${newOrganizer.email}` + ); + } +} + export function expectSuccessfulBookingRescheduledEmails({ emails, organizer, diff --git a/packages/app-store-cli/src/build.ts b/packages/app-store-cli/src/build.ts index 900ee25b56..68702b7710 100644 --- a/packages/app-store-cli/src/build.ts +++ b/packages/app-store-cli/src/build.ts @@ -104,10 +104,9 @@ function generateFiles() { * If a file has index.ts or index.tsx, it can be imported after removing the index.ts* part */ function getModulePath(path: string, moduleName: string) { - return ( - `./${path.replace(/\\/g, "/")}/` + - moduleName.replace(/\/index\.ts|\/index\.tsx/, "").replace(/\.tsx$|\.ts$/, "") - ); + return `./${path.replace(/\\/g, "/")}/${moduleName + .replace(/\/index\.ts|\/index\.tsx/, "") + .replace(/\.tsx$|\.ts$/, "")}`; } type ImportConfig = diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index 6c41d76ca2..cfc679e4b1 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -323,7 +323,8 @@ export default class EventManager { public async reschedule( event: CalendarEvent, rescheduleUid: string, - newBookingId?: number + newBookingId?: number, + changedOrganizer?: boolean ): Promise { const originalEvt = processLocation(event); const evt = cloneDeep(originalEvt); @@ -376,32 +377,37 @@ export default class EventManager { // As the reschedule requires confirmation, we can't update the events and meetings to new time yet. So, just delete them and let it be handled when organizer confirms the booking. await this.deleteEventsAndMeetings({ booking, event }); } else { - // If the reschedule doesn't require confirmation, we can "update" the events and meetings to new time. - const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null; - // If and only if event type is a dedicated meeting, update the dedicated video meeting. - if (isDedicated) { - const result = await this.updateVideoEvent(evt, booking); - const [updatedEvent] = Array.isArray(result.updatedEvent) - ? result.updatedEvent - : [result.updatedEvent]; + if (changedOrganizer) { + log.debug("RescheduleOrganizerChanged: Deleting Event and Meeting for previous booking"); + await this.deleteEventsAndMeetings({ booking, event }); + // New event is created in handleNewBooking + } else { + // If the reschedule doesn't require confirmation, we can "update" the events and meetings to new time. + const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null; + // If and only if event type is a dedicated meeting, update the dedicated video meeting. + if (isDedicated) { + const result = await this.updateVideoEvent(evt, booking); + const [updatedEvent] = Array.isArray(result.updatedEvent) + ? result.updatedEvent + : [result.updatedEvent]; - if (updatedEvent) { - evt.videoCallData = updatedEvent; - evt.location = updatedEvent.url; + if (updatedEvent) { + evt.videoCallData = updatedEvent; + evt.location = updatedEvent.url; + } + results.push(result); } - results.push(result); - } - const bookingCalendarReference = booking.references.find((reference) => - reference.type.includes("_calendar") - ); - // There was a case that booking didn't had any reference and we don't want to throw error on function - if (bookingCalendarReference) { - // Update all calendar events. - results.push(...(await this.updateAllCalendarEvents(evt, booking, newBookingId))); + const bookingCalendarReference = booking.references.find((reference) => + reference.type.includes("_calendar") + ); + // There was a case that booking didn't had any reference and we don't want to throw error on function + if (bookingCalendarReference) { + // Update all calendar events. + results.push(...(await this.updateAllCalendarEvents(evt, booking, newBookingId))); + } } } - const bookingPayment = booking?.payment; // Updating all payment to new diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 98f6ed7b39..8774278fa1 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -102,6 +102,37 @@ export const sendScheduledEmails = async ( await Promise.all(emailsToSend); }; +// for rescheduled round robin booking that assigned new members +export const sendRoundRobinScheduledEmails = async (calEvent: CalendarEvent, members: Person[]) => { + const emailsToSend: Promise[] = []; + + for (const teamMember of members) { + emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent, teamMember }))); + } + + await Promise.all(emailsToSend); +}; + +export const sendRoundRobinRescheduledEmails = async (calEvent: CalendarEvent, members: Person[]) => { + const emailsToSend: Promise[] = []; + + for (const teamMember of members) { + emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent, teamMember }))); + } + + await Promise.all(emailsToSend); +}; + +export const sendRoundRobinCancelledEmails = async (calEvent: CalendarEvent, members: Person[]) => { + const emailsToSend: Promise[] = []; + + for (const teamMember of members) { + emailsToSend.push(sendEmail(() => new OrganizerCancelledEmail({ calEvent, teamMember }))); + } + + await Promise.all(emailsToSend); +}; + export const sendRescheduledEmails = async (calEvent: CalendarEvent) => { const emailsToSend: Promise[] = []; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index dd00afc82c..af2f70ee70 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -30,6 +30,9 @@ import { sendOrganizerRequestEmail, sendRescheduledEmails, sendRescheduledSeatEmail, + sendRoundRobinCancelledEmails, + sendRoundRobinRescheduledEmails, + sendRoundRobinScheduledEmails, sendScheduledEmails, sendScheduledSeatsEmails, } from "@calcom/emails"; @@ -467,8 +470,26 @@ async function getOriginalRescheduledBooking(uid: string, seatsEventType?: boole email: true, locale: true, timeZone: true, + destinationCalendar: true, + credentials: { + select: { + id: true, + userId: true, + key: true, + type: true, + teamId: true, + appId: true, + invalid: true, + user: { + select: { + email: true, + }, + }, + }, + }, }, }, + destinationCalendar: true, payment: true, references: true, workflowReminders: true, @@ -663,8 +684,6 @@ async function handler( }; const { recurringCount, - allRecurringDates, - currentRecurringIndex, noEmail, eventTypeId, eventTypeSlug, @@ -900,6 +919,7 @@ async function handler( let originalRescheduledBooking: BookingType = null; + //this gets the orginal rescheduled booking if (rescheduleUid) { // rescheduleUid can be bookingUid and bookingSeatUid bookingSeat = await prisma.bookingSeat.findUnique({ @@ -923,6 +943,7 @@ async function handler( } } + //checks what users are available if (!eventType.seatsPerTimeSlot && !skipAvailabilityCheck) { const availableUsers = await ensureAvailableUsers( { @@ -1897,11 +1918,16 @@ async function handler( evt.recurringEvent = eventType.recurringEvent; } + const changedOrganizer = + !!originalRescheduledBooking && + eventType.schedulingType === SchedulingType.ROUND_ROBIN && + originalRescheduledBooking.userId !== evt.organizer.id; + async function createBooking() { if (originalRescheduledBooking) { evt.title = originalRescheduledBooking?.title || evt.title; evt.description = originalRescheduledBooking?.description || evt.description; - evt.location = originalRescheduledBooking?.location || evt.location; + evt.location = changedOrganizer ? evt.location : originalRescheduledBooking?.location || evt.location; } const eventTypeRel = !eventTypeId @@ -2146,6 +2172,7 @@ async function handler( let videoCallUrl; + //this is the actual rescheduling logic if (originalRescheduledBooking?.uid) { log.silly("Rescheduling booking", originalRescheduledBooking.uid); try { @@ -2158,9 +2185,7 @@ async function handler( ); } - // Use EventManager to conditionally use all needed integrations. addVideoCallDataToEvt(originalRescheduledBooking.references); - const updateManager = await eventManager.reschedule(evt, originalRescheduledBooking.uid); //update original rescheduled booking (no seats event) if (!eventType.seatsPerTimeSlot) { @@ -2175,6 +2200,21 @@ async function handler( }); } + const newDesinationCalendar = evt.destinationCalendar; + + evt.destinationCalendar = originalRescheduledBooking?.destinationCalendar + ? [originalRescheduledBooking?.destinationCalendar] + : originalRescheduledBooking?.user?.destinationCalendar + ? [originalRescheduledBooking?.user.destinationCalendar] + : evt.destinationCalendar; + + const updateManager = await eventManager.reschedule( + evt, + originalRescheduledBooking.uid, + undefined, + changedOrganizer + ); + // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back // to the default description when we are sending the emails. evt.description = eventType.description; @@ -2200,23 +2240,179 @@ async function handler( : calendarResult?.updatedEvent?.iCalUID || undefined; } - const { metadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({ + const { metadata: videoMetadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({ results, }); + let metadata: AdditionalInformation = {}; + metadata = videoMetadata; videoCallUrl = _videoCallUrl; + + //if organizer changed we need to create a new booking (reschedule only cancels the old one) + if (changedOrganizer) { + evt.destinationCalendar = newDesinationCalendar; + evt.title = getEventName(eventNameObject); + + // location might changed and will be new created in eventManager.create (organizer default location) + evt.videoCallData = undefined; + const createManager = await eventManager.create(evt); + + // This gets overridden when creating the event - to check if notes have been hidden or not. We just reset this back + // to the default description when we are sending the emails. + evt.description = eventType.description; + + results = createManager.results; + referencesToCreate = createManager.referencesToCreate; + videoCallUrl = evt.videoCallData && evt.videoCallData.url ? evt.videoCallData.url : null; + + if (results.length > 0 && results.every((res) => !res.success)) { + const error = { + errorCode: "BookingCreatingMeetingFailed", + message: "Booking rescheduling failed", + }; + + loggerWithEventDetails.error( + `EventManager.create failure in some of the integrations ${organizerUser.username}`, + safeStringify({ error, results }) + ); + } else { + if (results.length) { + // Handle Google Meet results + // We use the original booking location since the evt location changes to daily + if (bookingLocation === MeetLocationType) { + const googleMeetResult = { + appName: GoogleMeetMetadata.name, + type: "conferencing", + uid: results[0].uid, + originalEvent: results[0].originalEvent, + }; + + // Find index of google_calendar inside createManager.referencesToCreate + const googleCalIndex = createManager.referencesToCreate.findIndex( + (ref) => ref.type === "google_calendar" + ); + const googleCalResult = results[googleCalIndex]; + + if (!googleCalResult) { + loggerWithEventDetails.warn("Google Calendar not installed but using Google Meet as location"); + results.push({ + ...googleMeetResult, + success: false, + calWarnings: [tOrganizer("google_meet_warning")], + }); + } + + if (googleCalResult?.createdEvent?.hangoutLink) { + results.push({ + ...googleMeetResult, + success: true, + }); + + // Add google_meet to referencesToCreate in the same index as google_calendar + createManager.referencesToCreate[googleCalIndex] = { + ...createManager.referencesToCreate[googleCalIndex], + meetingUrl: googleCalResult.createdEvent.hangoutLink, + }; + + // Also create a new referenceToCreate with type video for google_meet + createManager.referencesToCreate.push({ + type: "google_meet_video", + meetingUrl: googleCalResult.createdEvent.hangoutLink, + uid: googleCalResult.uid, + credentialId: createManager.referencesToCreate[googleCalIndex].credentialId, + }); + } else if (googleCalResult && !googleCalResult.createdEvent?.hangoutLink) { + results.push({ + ...googleMeetResult, + success: false, + }); + } + } + + metadata.hangoutLink = results[0].createdEvent?.hangoutLink; + metadata.conferenceData = results[0].createdEvent?.conferenceData; + metadata.entryPoints = results[0].createdEvent?.entryPoints; + evt.appsStatus = handleAppsStatus(results, booking); + videoCallUrl = + metadata.hangoutLink || + results[0].createdEvent?.url || + organizerOrFirstDynamicGroupMemberDefaultLocationUrl || + videoCallUrl; + } + } + } + evt.appsStatus = handleAppsStatus(results, booking); // If there is an integration error, we don't send successful rescheduling email, instead broken integration email should be sent that are handled by either CalendarManager or videoClient if (noEmail !== true && isConfirmedByDefault && !isThereAnIntegrationError) { const copyEvent = cloneDeep(evt); - loggerWithEventDetails.debug("Emails: Sending rescheduled emails for booking confirmation"); - await sendRescheduledEmails({ + const copyEventAdditionalInfo = { ...copyEvent, additionalInformation: metadata, additionalNotes, // Resets back to the additionalNote input and not the override value cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email - }); + }; + loggerWithEventDetails.debug("Emails: Sending rescheduled emails for booking confirmation"); + + /* + handle emails for round robin + - if booked rr host is the same, then rescheduling email + - if new rr host is booked, then cancellation email to old host and confirmation email to new host + */ + if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) { + const originalBookingMemberEmails: Person[] = []; + + for (const user of originalRescheduledBooking.attendees) { + const translate = await getTranslation(user.locale ?? "en", "common"); + originalBookingMemberEmails.push({ + name: user.name, + email: user.email, + timeZone: user.timeZone, + language: { translate, locale: user.locale ?? "en" }, + }); + } + if (originalRescheduledBooking.user) { + const translate = await getTranslation(originalRescheduledBooking.user.locale ?? "en", "common"); + originalBookingMemberEmails.push({ + ...originalRescheduledBooking.user, + name: originalRescheduledBooking.user.name || "", + language: { translate, locale: originalRescheduledBooking.user.locale ?? "en" }, + }); + } + + const newBookingMemberEmails: Person[] = + copyEvent.team?.members + .map((member) => member) + .concat(copyEvent.organizer) + .concat(copyEvent.attendees) || []; + + // scheduled Emails + const newBookedMembers = newBookingMemberEmails.filter( + (member) => + !originalBookingMemberEmails.find((originalMember) => originalMember.email === member.email) + ); + // cancelled Emails + const cancelledMembers = originalBookingMemberEmails.filter( + (member) => !newBookingMemberEmails.find((newMember) => newMember.email === member.email) + ); + // rescheduled Emails + const rescheduledMembers = newBookingMemberEmails.filter((member) => + originalBookingMemberEmails.find((orignalMember) => orignalMember.email === member.email) + ); + + sendRoundRobinRescheduledEmails(copyEventAdditionalInfo, rescheduledMembers); + sendRoundRobinScheduledEmails(copyEventAdditionalInfo, newBookedMembers); + sendRoundRobinCancelledEmails(copyEventAdditionalInfo, cancelledMembers); + } else { + // send normal rescheduled emails (non round robin event, where organizers stay the same) + await sendRescheduledEmails({ + ...copyEvent, + additionalInformation: metadata, + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email + }); + } } // If it's not a reschedule, doesn't require confirmation and there's no price, // Create a booking @@ -2376,7 +2572,7 @@ async function handler( const metadata = videoCallUrl ? { - videoCallUrl: getVideoCallUrlFromCalEvent(evt), + videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, } : undefined; diff --git a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts index 50c1fe2baa..622b88e950 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts @@ -5,7 +5,7 @@ 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 { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; import { createBookingScenario, @@ -37,6 +37,7 @@ import { expectBookingRequestedWebhookToHaveBeenFired, expectSuccessfulCalendarEventDeletionInCalendar, expectSuccessfulVideoMeetingDeletionInCalendar, + expectSuccessfulRoudRobinReschedulingEmails, } from "@calcom/web/test/utils/bookingScenario/expects"; import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; @@ -48,839 +49,642 @@ 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`, - metadata: { - videoCallUrl: "https://existing-daily-video-call-url.example.com", - }, - references: [ - { - type: appStoreMetadata.dailyvideo.type, - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - credentialId: null, - }, - { - 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", - appsStatus: [ - getMockPassingAppStatus({ slug: appStoreMetadata.dailyvideo.slug }), - getMockPassingAppStatus({ slug: appStoreMetadata.googlecalendar.slug }), - ], - }); - - expectBookingRescheduledWebhookToHaveBeenFired({ - booker, - organizer, - location: BookingLocations.CalVideo, - subscriberUrl: "http://my-webhook.example.com", - videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.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/${createdBooking.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`, - metadata: { - videoCallUrl: "https://existing-daily-video-call-url.example.com", - }, - 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 _videoMock = mockSuccessfulVideoMeetingCreation({ - metadataLookupKey: "dailyvideo", - }); - - const mockBookingData = getMockRequestDataForBooking({ - data: { - eventTypeId: 1, - rescheduleUid: uidOfBookingToBeRescheduled, - 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 expectBookingInDBToBeRescheduledFromTo({ - from: { - uid: uidOfBookingToBeRescheduled, - }, - to: { - description: "", - location: "integrations:daily", - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: createdBooking.uid!, - eventTypeId: mockBookingData.eventTypeId, - status: BookingStatus.ACCEPTED, - metadata: { - videoCallUrl: `${WEBAPP_URL}/video/${createdBooking?.uid}`, - }, - responses: expect.objectContaining({ - email: booker.email, - name: booker.name, - }), - // Booking References still use the original booking's references - Not sure how intentional it is. - references: [ - { - type: appStoreMetadata.dailyvideo.type, - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - }, - { - 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: "integrations:daily", - subscriberUrl: "http://my-webhook.example.com", - payload: { - uid: createdBooking.uid, - appsStatus: [ - expect.objectContaining(getMockPassingAppStatus({ slug: appStoreMetadata.dailyvideo.slug })), - expect.objectContaining( - getMockFailingAppStatus({ slug: appStoreMetadata.googlecalendar.slug }) - ), - ], - }, - videoCallUrl: `${WEBAPP_URL}/video/${createdBooking?.uid}`, - }); - }, - timeout - ); - - describe("Event Type that requires confirmation", () => { + describe("User event-type", () => { test( - `should reschedule a booking that requires confirmation in PENDING state - When a booker(who is not the organizer himself) is doing the reschedule + `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`, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + { + 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", + appsStatus: [ + getMockPassingAppStatus({ slug: appStoreMetadata.dailyvideo.slug }), + getMockPassingAppStatus({ slug: appStoreMetadata.googlecalendar.slug }), + ], + }); + + expectBookingRescheduledWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.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/${createdBooking.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`, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + 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 _videoMock = mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + rescheduleUid: uidOfBookingToBeRescheduled, + 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 expectBookingInDBToBeRescheduledFromTo({ + from: { + uid: uidOfBookingToBeRescheduled, + }, + to: { + description: "", + location: "integrations:daily", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + metadata: { + videoCallUrl: `${WEBAPP_URL}/video/${createdBooking?.uid}`, + }, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + // Booking References still use the original booking's references - Not sure how intentional it is. + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + 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: "integrations:daily", + subscriberUrl: "http://my-webhook.example.com", + payload: { + uid: createdBooking.uid, + appsStatus: [ + expect.objectContaining(getMockPassingAppStatus({ slug: appStoreMetadata.dailyvideo.slug })), + expect.objectContaining( + getMockFailingAppStatus({ slug: appStoreMetadata.googlecalendar.slug }) + ), + ], + }, + videoCallUrl: `${WEBAPP_URL}/video/${createdBooking?.uid}`, + }); + }, + timeout + ); + + describe("Event Type that requires confirmation", () => { + test( + `should reschedule a booking that requires confirmation in PENDING state - When a booker(who is not the organizer himself) is doing the reschedule 1. Should cancel the existing booking 2. Should delete existing calendar invite and Video meeting 2. Should create a new booking in the database in PENDING state 3. Should send BOOKING Requested scenario emails to the booker as well as organizer 4. Should trigger BOOKING_REQUESTED webhook instead of BOOKING_RESCHEDULED `, - async ({ emails }) => { - const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; - const subscriberUrl = "http://my-webhook.example.com"; - const booker = getBooker({ - email: "booker@example.com", - name: "Booker", - }); + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const subscriberUrl = "http://my-webhook.example.com"; + 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"; + 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"; - const scenarioData = getScenarioData({ - webhooks: [ - { - userId: organizer.id, - eventTriggers: ["BOOKING_CREATED"], - subscriberUrl, - active: true, - eventTypeId: 1, - appId: null, - }, - ], - eventTypes: [ - { - id: 1, - slotInterval: 45, - requiresConfirmation: true, - 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: [ - getMockBookingReference({ - type: appStoreMetadata.dailyvideo.type, - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - credentialId: 0, - }), - getMockBookingReference({ - type: appStoreMetadata.googlecalendar.type, - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASSWORD", - meetingUrl: "https://UNUSED_URL", - externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", - credentialId: 1, - }), - ], - }, - ], - organizer, - apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], - }); - await createBookingScenario(scenarioData); - - 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); - expect(createdBooking.responses).toContain({ - email: booker.email, - name: booker.name, - }); - - await expectBookingInDBToBeRescheduledFromTo({ - from: { - uid: uidOfBookingToBeRescheduled, - }, - to: { - description: "", - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: createdBooking.uid!, - eventTypeId: mockBookingData.eventTypeId, - // Rescheduled booking sill stays in pending state - status: BookingStatus.PENDING, - 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(); - - expectBookingRequestedEmails({ - booker, - organizer, - emails, - }); - - expectBookingRequestedWebhookToHaveBeenFired({ - booker, - organizer, - location: BookingLocations.CalVideo, - subscriberUrl, - eventType: scenarioData.eventTypes[0], - }); - - expectSuccessfulVideoMeetingDeletionInCalendar(videoMock, { - bookingRef: { - type: appStoreMetadata.dailyvideo.type, - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - }, - }); - - expectSuccessfulCalendarEventDeletionInCalendar(calendarMock, { - externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", - calEvent: { - videoCallData: expect.objectContaining({ - url: "http://mock-dailyvideo.example.com", - }), - }, - uid: "MOCK_ID", - }); - }, - timeout - ); - - test( - `should rechedule a booking, that requires confirmation, without confirmation - When booker is the organizer of the existing booking as well as the event-type - 1. Should cancel the existing booking - 2. Should delete existing calendar invite and Video meeting - 2. Should create a new booking in the database in ACCEPTED state - 3. Should send rescheduled 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({ + const scenarioData = getScenarioData({ webhooks: [ { userId: organizer.id, eventTriggers: ["BOOKING_CREATED"], - subscriberUrl: "http://my-webhook.example.com", + subscriberUrl, active: true, eventTypeId: 1, appId: null, @@ -889,441 +693,437 @@ describe("handleNewBooking", () => { eventTypes: [ { id: 1, - requiresConfirmation: true, slotInterval: 45, + requiresConfirmation: true, length: 45, users: [ { id: 101, }, ], - destinationCalendar: { - integration: "google_calendar", - externalId: "event-type-1@example.com", - }, }, ], bookings: [ { uid: uidOfBookingToBeRescheduled, eventTypeId: 1, - userId: organizer.id, status: BookingStatus.ACCEPTED, startTime: `${plus1DateString}T05:00:00.000Z`, endTime: `${plus1DateString}T05:15:00.000Z`, references: [ - { + getMockBookingReference({ type: appStoreMetadata.dailyvideo.type, uid: "MOCK_ID", meetingId: "MOCK_ID", meetingPassword: "MOCK_PASS", meetingUrl: "http://mock-dailyvideo.example.com", - }, - { + credentialId: 0, + }), + getMockBookingReference({ 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, - }, - ], - attendees: [ - getMockBookingAttendee({ - id: 1, - name: organizer.name, - email: organizer.email, - locale: "en", - timeZone: "Europe/London", - }), - getMockBookingAttendee({ - id: 2, - name: booker.name, - email: booker.email, - // Booker's locale when the fresh booking happened earlier - locale: "hi", - // Booker's timezone when the fresh booking happened earlier - timeZone: "Asia/Kolkata", + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + credentialId: 1, }), ], }, ], organizer, apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], - }) - ); + }); + await createBookingScenario(scenarioData); - const videoMock = mockSuccessfulVideoMeetingCreation({ - metadataLookupKey: "dailyvideo", - }); + 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`, - // Organizer is doing the rescheduling from his timezone which is different from Booker Timezone as per the booking being rescheduled - timeZone: "Europe/London", - responses: { - email: booker.email, - name: booker.name, - location: { optionValue: "", value: BookingLocations.CalVideo }, + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "MOCK_ID", }, - }, - }); + update: { + uid: "UPDATED_MOCK_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); - const { req } = createMockNextJsRequest({ - method: "POST", - body: mockBookingData, - }); - - // Fake the request to be from organizer - req.userId = organizer.id; - - 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", - attendees: expect.arrayContaining([ - expect.objectContaining({ + 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); + expect(createdBooking.responses).toContain({ + email: booker.email, + name: booker.name, + }); + + await expectBookingInDBToBeRescheduledFromTo({ + from: { + uid: uidOfBookingToBeRescheduled, + }, + to: { + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + // Rescheduled booking sill stays in pending state + status: BookingStatus.PENDING, + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ email: booker.email, name: booker.name, - // Expect that the booker timezone is his earlier timezone(from original booking), even though the rescheduling is done by organizer from his timezone - timeZone: "Asia/Kolkata", - language: expect.objectContaining({ - // Expect that the booker locale is his earlier locale(from original booking), even though the rescheduling is done by organizer with his locale - locale: "hi", - }), }), - ]), - }, - uid: "MOCK_ID", - }); + 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", + }, + ], + }, + }); - 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/${createdBooking.uid}`, - }); - }, - timeout - ); + expectWorkflowToBeTriggered(); - test( - `should rechedule a booking, that requires confirmation, in PENDING state - Even when the rescheduler is the organizer of the event-type but not the organizer of the existing booking + expectBookingRequestedEmails({ + booker, + organizer, + emails, + }); + + expectBookingRequestedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl, + eventType: scenarioData.eventTypes[0], + }); + + expectSuccessfulVideoMeetingDeletionInCalendar(videoMock, { + bookingRef: { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + }); + + expectSuccessfulCalendarEventDeletionInCalendar(calendarMock, { + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + calEvent: { + videoCallData: expect.objectContaining({ + url: "http://mock-dailyvideo.example.com", + }), + }, + uid: "MOCK_ID", + }); + }, + timeout + ); + + test( + `should rechedule a booking, that requires confirmation, without confirmation - When booker is the organizer of the existing booking as well as the event-type + 1. Should cancel the existing booking + 2. Should delete existing calendar invite and Video meeting + 2. Should create a new booking in the database in ACCEPTED state + 3. Should send rescheduled 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, + requiresConfirmation: true, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@example.com", + }, + }, + ], + bookings: [ + { + uid: uidOfBookingToBeRescheduled, + eventTypeId: 1, + userId: organizer.id, + 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, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 1, + name: organizer.name, + email: organizer.email, + locale: "en", + timeZone: "Europe/London", + }), + getMockBookingAttendee({ + id: 2, + name: booker.name, + email: booker.email, + // Booker's locale when the fresh booking happened earlier + locale: "hi", + // Booker's timezone when the fresh booking happened earlier + timeZone: "Asia/Kolkata", + }), + ], + }, + ], + 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`, + // Organizer is doing the rescheduling from his timezone which is different from Booker Timezone as per the booking being rescheduled + timeZone: "Europe/London", + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + // Fake the request to be from organizer + req.userId = organizer.id; + + 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", + attendees: expect.arrayContaining([ + expect.objectContaining({ + email: booker.email, + name: booker.name, + // Expect that the booker timezone is his earlier timezone(from original booking), even though the rescheduling is done by organizer from his timezone + timeZone: "Asia/Kolkata", + language: expect.objectContaining({ + // Expect that the booker locale is his earlier locale(from original booking), even though the rescheduling is done by organizer with his locale + locale: "hi", + }), + }), + ]), + }, + 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/${createdBooking.uid}`, + }); + }, + timeout + ); + + test( + `should rechedule a booking, that requires confirmation, in PENDING state - Even when the rescheduler is the organizer of the event-type but not the organizer of the existing booking 1. Should cancel the existing booking 2. Should delete existing calendar invite and Video meeting 2. Should create a new booking in the database in PENDING state 3. Should send booking requested emails to the booker as well as organizer 4. Should trigger BOOKING_REQUESTED webhook `, - async ({ emails }) => { - const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; - const subscriberUrl = "http://my-webhook.example.com"; - const booker = getBooker({ - email: "booker@example.com", - name: "Booker", - }); + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const subscriberUrl = "http://my-webhook.example.com"; + 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"; + 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"; - const scenarioData = getScenarioData({ - webhooks: [ - { - userId: organizer.id, - eventTriggers: ["BOOKING_CREATED"], - subscriberUrl, - active: true, - eventTypeId: 1, - appId: null, - }, - ], - eventTypes: [ - { - id: 1, - slotInterval: 45, - requiresConfirmation: true, - 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: [ - getMockBookingReference({ - type: appStoreMetadata.dailyvideo.type, - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - credentialId: 0, - }), - getMockBookingReference({ - type: appStoreMetadata.googlecalendar.type, - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASSWORD", - meetingUrl: "https://UNUSED_URL", - externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", - credentialId: 1, - }), - ], - }, - ], - organizer, - apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], - }); - await createBookingScenario(scenarioData); - - 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, - }); - - // Fake the request to be from organizer - req.userId = organizer.id; - - const createdBooking = await handleNewBooking(req); - expect(createdBooking.responses).toContain({ - email: booker.email, - name: booker.name, - }); - - await expectBookingInDBToBeRescheduledFromTo({ - from: { - uid: uidOfBookingToBeRescheduled, - }, - to: { - description: "", - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: createdBooking.uid!, - eventTypeId: mockBookingData.eventTypeId, - // Rescheduled booking sill stays in pending state - status: BookingStatus.PENDING, - 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(); - - expectBookingRequestedEmails({ - booker, - organizer, - emails, - }); - - expectBookingRequestedWebhookToHaveBeenFired({ - booker, - organizer, - location: BookingLocations.CalVideo, - subscriberUrl, - eventType: scenarioData.eventTypes[0], - }); - - expectSuccessfulVideoMeetingDeletionInCalendar(videoMock, { - bookingRef: { - type: appStoreMetadata.dailyvideo.type, - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - }, - }); - - expectSuccessfulCalendarEventDeletionInCalendar(calendarMock, { - externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", - calEvent: { - videoCallData: expect.objectContaining({ - url: "http://mock-dailyvideo.example.com", - }), - }, - uid: "MOCK_ID", - }); - }, - timeout - ); - - test( - `should rechedule a booking, that requires confirmation, without confirmation - When the owner of the previous booking is doing the reschedule(but he isn't the organizer of the event-type now) - 1. Should cancel the existing booking - 2. Should delete existing calendar invite and Video meeting - 2. Should create a new booking in the database in ACCEPTED state - 3. Should send rescheduled 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"; - const previousOrganizerIdForTheBooking = 1001; - await createBookingScenario( - getScenarioData({ + const scenarioData = getScenarioData({ webhooks: [ { userId: organizer.id, eventTriggers: ["BOOKING_CREATED"], - subscriberUrl: "http://my-webhook.example.com", + subscriberUrl, active: true, eventTypeId: 1, appId: null, @@ -1332,104 +1132,509 @@ describe("handleNewBooking", () => { eventTypes: [ { id: 1, - requiresConfirmation: true, slotInterval: 45, + requiresConfirmation: true, length: 45, users: [ { id: 101, }, ], - destinationCalendar: { - integration: "google_calendar", - externalId: "event-type-1@example.com", - }, }, ], bookings: [ { uid: uidOfBookingToBeRescheduled, eventTypeId: 1, - // Make sure that the earlier booking owner is some user with ID 10001 - userId: previousOrganizerIdForTheBooking, status: BookingStatus.ACCEPTED, startTime: `${plus1DateString}T05:00:00.000Z`, endTime: `${plus1DateString}T05:15:00.000Z`, references: [ - { + getMockBookingReference({ type: appStoreMetadata.dailyvideo.type, uid: "MOCK_ID", meetingId: "MOCK_ID", meetingPassword: "MOCK_PASS", meetingUrl: "http://mock-dailyvideo.example.com", - }, - { + credentialId: 0, + }), + getMockBookingReference({ 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, - }, - ], - attendees: [ - getMockBookingAttendee({ - id: 1, - name: organizer.name, - email: organizer.email, - locale: "en", - timeZone: "Europe/London", - }), - getMockBookingAttendee({ - id: 2, - name: booker.name, - email: booker.email, - // Booker's locale when the fresh booking happened earlier - locale: "hi", - // Booker's timezone when the fresh booking happened earlier - timeZone: "Asia/Kolkata", + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + credentialId: 1, }), ], }, ], organizer, - usersApartFromOrganizer: [ + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }); + await createBookingScenario(scenarioData); + + 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, + }); + + // Fake the request to be from organizer + req.userId = organizer.id; + + const createdBooking = await handleNewBooking(req); + expect(createdBooking.responses).toContain({ + email: booker.email, + name: booker.name, + }); + + await expectBookingInDBToBeRescheduledFromTo({ + from: { + uid: uidOfBookingToBeRescheduled, + }, + to: { + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + // Rescheduled booking sill stays in pending state + status: BookingStatus.PENDING, + 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(); + + expectBookingRequestedEmails({ + booker, + organizer, + emails, + }); + + expectBookingRequestedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl, + eventType: scenarioData.eventTypes[0], + }); + + expectSuccessfulVideoMeetingDeletionInCalendar(videoMock, { + bookingRef: { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + }); + + expectSuccessfulCalendarEventDeletionInCalendar(calendarMock, { + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + calEvent: { + videoCallData: expect.objectContaining({ + url: "http://mock-dailyvideo.example.com", + }), + }, + uid: "MOCK_ID", + }); + }, + timeout + ); + + test( + `should rechedule a booking, that requires confirmation, without confirmation - When the owner of the previous booking is doing the reschedule(but he isn't the organizer of the event-type now) + 1. Should cancel the existing booking + 2. Should delete existing calendar invite and Video meeting + 2. Should create a new booking in the database in ACCEPTED state + 3. Should send rescheduled 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"; + const previousOrganizerIdForTheBooking = 1001; + 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, + requiresConfirmation: true, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@example.com", + }, + }, + ], + bookings: [ + { + uid: uidOfBookingToBeRescheduled, + eventTypeId: 1, + // Make sure that the earlier booking owner is some user with ID 10001 + userId: previousOrganizerIdForTheBooking, + 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, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 1, + name: organizer.name, + email: organizer.email, + locale: "en", + timeZone: "Europe/London", + }), + getMockBookingAttendee({ + id: 2, + name: booker.name, + email: booker.email, + // Booker's locale when the fresh booking happened earlier + locale: "hi", + // Booker's timezone when the fresh booking happened earlier + timeZone: "Asia/Kolkata", + }), + ], + }, + ], + organizer, + usersApartFromOrganizer: [ + { + id: previousOrganizerIdForTheBooking, + name: "Previous Organizer", + email: "", + schedules: [TestData.schedules.IstWorkHours], + username: "prev-organizer", + timeZone: "Europe/London", + }, + ], + 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`, + // Organizer is doing the rescheduling from his timezone which is different from Booker Timezone as per the booking being rescheduled + timeZone: "Europe/London", + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + // Fake the request to be from organizer + req.userId = previousOrganizerIdForTheBooking; + + 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", + attendees: expect.arrayContaining([ + expect.objectContaining({ + email: booker.email, + name: booker.name, + // Expect that the booker timezone is his earlier timezone(from original booking), even though the rescheduling is done by organizer from his timezone + timeZone: "Asia/Kolkata", + language: expect.objectContaining({ + // Expect that the booker locale is his earlier locale(from original booking), even though the rescheduling is done by organizer with his locale + locale: "hi", + }), + }), + ]), + }, + 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/${createdBooking.uid}`, + }); + }, + timeout + ); + }); + }); + describe("Team event-type", () => { + test( + "should send correct schedule/cancellation emails to hosts when round robin is rescheduled to different host", + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const roundRobinHost1 = getOrganizer({ + name: "RR Host 1", + email: "rrhost1@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const roundRobinHost2 = getOrganizer({ + name: "RR Host 2", + email: "rrhost2@example.com", + id: 102, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + await createBookingScenario( + getScenarioData({ + eventTypes: [ { - id: previousOrganizerIdForTheBooking, - name: "Previous Organizer", - email: "", - schedules: [TestData.schedules.IstWorkHours], - username: "prev-organizer", - timeZone: "Europe/London", + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + schedulingType: SchedulingType.ROUND_ROBIN, }, ], + bookings: [ + { + uid: uidOfBookingToBeRescheduled, + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + }, + ], + organizer: roundRobinHost1, + usersApartFromOrganizer: [roundRobinHost2], apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], }) ); - const videoMock = mockSuccessfulVideoMeetingCreation({ + mockSuccessfulVideoMeetingCreation({ metadataLookupKey: "dailyvideo", }); - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + mockCalendarToHaveNoBusySlots("googlecalendar", { create: { uid: "MOCK_ID", }, update: { - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", uid: "UPDATED_MOCK_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); const mockBookingData = getMockRequestDataForBooking({ data: { eventTypeId: 1, + user: roundRobinHost1.name, rescheduleUid: uidOfBookingToBeRescheduled, start: `${plus1DateString}T04:00:00.000Z`, end: `${plus1DateString}T04:15:00.000Z`, - // Organizer is doing the rescheduling from his timezone which is different from Booker Timezone as per the booking being rescheduled - timeZone: "Europe/London", responses: { email: booker.email, name: booker.name, @@ -1437,17 +1642,32 @@ describe("handleNewBooking", () => { }, }, }); - const { req } = createMockNextJsRequest({ method: "POST", body: mockBookingData, }); - // Fake the request to be from organizer - req.userId = previousOrganizerIdForTheBooking; - 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 */ @@ -1469,76 +1689,163 @@ describe("handleNewBooking", () => { email: booker.email, name: booker.name, }), - references: [ + }, + }); + + expectSuccessfulRoudRobinReschedulingEmails({ + prevOrganizer: roundRobinHost1, + newOrganizer: roundRobinHost2, + emails, + }); + }, + timeout + ); + + test( + "should send rescheduling emails when round robin is rescheduled to same host", + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const roundRobinHost1 = getOrganizer({ + name: "RR Host 1", + email: "rrhost1@example.com", + id: 101, + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const roundRobinHost2 = getOrganizer({ + name: "RR Host 2", + email: "rrhost2@example.com", + id: 102, + schedules: [TestData.schedules.IstEveningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + await createBookingScenario( + getScenarioData({ + eventTypes: [ { - 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", + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + schedulingType: SchedulingType.ROUND_ROBIN, }, ], - }, + bookings: [ + { + uid: uidOfBookingToBeRescheduled, + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + }, + ], + organizer: roundRobinHost1, + usersApartFromOrganizer: [roundRobinHost2], + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", }); - expectWorkflowToBeTriggered(); - - expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, { - calEvent: { - location: "http://mock-dailyvideo.example.com", - }, - bookingRef: { - type: appStoreMetadata.dailyvideo.type, + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", + }, + update: { + uid: "UPDATED_MOCK_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); - // 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", - attendees: expect.arrayContaining([ - expect.objectContaining({ - email: booker.email, - name: booker.name, - // Expect that the booker timezone is his earlier timezone(from original booking), even though the rescheduling is done by organizer from his timezone - timeZone: "Asia/Kolkata", - language: expect.objectContaining({ - // Expect that the booker locale is his earlier locale(from original booking), even though the rescheduling is done by organizer with his locale - locale: "hi", - }), - }), - ]), + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + user: roundRobinHost1.name, + 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 }, + }, }, - uid: "MOCK_ID", + }); + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, }); - expectSuccessfulBookingRescheduledEmails({ - booker, - organizer, + 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, + }), + }, + }); + + expectSuccessfulRoudRobinReschedulingEmails({ + prevOrganizer: roundRobinHost1, + newOrganizer: roundRobinHost1, // Round robin host 2 is not available and it will be rescheduled to same user emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - }); - - expectBookingRescheduledWebhookToHaveBeenFired({ - booker, - organizer, - location: BookingLocations.CalVideo, - subscriberUrl: "http://my-webhook.example.com", - videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`, }); }, timeout diff --git a/packages/features/ee/organizations/pages/settings/other-team-members-view.tsx b/packages/features/ee/organizations/pages/settings/other-team-members-view.tsx index 765ab30c36..1e37ceaba3 100644 --- a/packages/features/ee/organizations/pages/settings/other-team-members-view.tsx +++ b/packages/features/ee/organizations/pages/settings/other-team-members-view.tsx @@ -1,10 +1,11 @@ // import { debounce } from "lodash"; import { useSession } from "next-auth/react"; -import { useSearchParams, useRouter } from "next/navigation"; +import { useRouter } from "next/navigation"; import { useState, useEffect } from "react"; import MemberInvitationModal from "@calcom/ee/teams/components/MemberInvitationModal"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; import { MembershipRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react"; @@ -57,8 +58,8 @@ function MembersList(props: MembersListProps) { const MembersView = () => { const { t, i18n } = useLocale(); const router = useRouter(); - const searchParams = useSearchParams(); - const teamId = Number(searchParams?.get("id")); + const params = useParamsWithFallback(); + const teamId = Number(params.id); const session = useSession(); const utils = trpc.useContext(); const [offset, setOffset] = useState(1); diff --git a/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx b/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx index 1b04688418..dac0d4aabf 100644 --- a/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx +++ b/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx @@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import type { Prisma } from "@prisma/client"; import { useSession } from "next-auth/react"; import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { useState, useLayoutEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; @@ -10,6 +10,7 @@ import { z } from "zod"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; import { md } from "@calcom/lib/markdownIt"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; import objectKeys from "@calcom/lib/objectKeys"; @@ -76,8 +77,8 @@ const OtherTeamProfileView = () => { const form = useForm({ resolver: zodResolver(teamProfileFormSchema), }); - const searchParams = useSearchParams(); - const teamId = Number(searchParams?.get("id")); + const params = useParamsWithFallback(); + const teamId = Number(params.id); const { data: team, isLoading } = trpc.viewer.organizations.getOtherTeam.useQuery( { teamId: teamId }, { diff --git a/packages/features/embed/lib/EmbedCodes.tsx b/packages/features/embed/lib/EmbedCodes.tsx index f8b1c83bc5..53ca519e94 100644 --- a/packages/features/embed/lib/EmbedCodes.tsx +++ b/packages/features/embed/lib/EmbedCodes.tsx @@ -1,9 +1,15 @@ -import { IS_SELF_HOSTED } from "@calcom/lib/constants"; +import { CAL_URL, IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants"; import type { PreviewState } from "../types"; import { embedLibUrl } from "./constants"; import { getDimension } from "./getDimension"; +export const doWeNeedCalOriginProp = (embedCalOrigin: string) => { + // If we are self hosted, calOrigin won't be app.cal.com so we need to pass it + // If we are not self hosted but it's still different from WEBAPP_URL and CAL_URL, we need to pass it -> It happens for organization booking URL at the moment + return IS_SELF_HOSTED || (embedCalOrigin !== WEBAPP_URL && embedCalOrigin !== CAL_URL); +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any export const Codes = { react: { @@ -33,13 +39,9 @@ export const Codes = { return ; };`; }, @@ -53,7 +55,7 @@ export const Codes = { return code` import { getCalApi } from "@calcom/embed-react"; import { useEffect } from "react"; - export default function App() { + export default function MyApp() { useEffect(()=>{ (async function () { const cal = await getCalApi(${IS_SELF_HOSTED ? `"${embedLibUrl}"` : ""}); @@ -77,7 +79,7 @@ export const Codes = { return code` import { getCalApi } from "@calcom/embed-react"; import { useEffect } from "react"; - export default function App() { + export default function MyApp() { useEffect(()=>{ (async function () { const cal = await getCalApi(${IS_SELF_HOSTED ? `"${embedLibUrl}"` : ""}); @@ -85,7 +87,8 @@ export const Codes = { })(); }, []) return