diff --git a/apps/web/lib/getTemporaryOrgRedirect.test.ts b/apps/web/lib/getTemporaryOrgRedirect.test.ts index 6e3d565104..f2705a02bc 100644 --- a/apps/web/lib/getTemporaryOrgRedirect.test.ts +++ b/apps/web/lib/getTemporaryOrgRedirect.test.ts @@ -1,11 +1,19 @@ import prismaMock from "../../../tests/libs/__mocks__/prismaMock"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { RedirectType } from "@calcom/prisma/client"; import { getTemporaryOrgRedirect } from "./getTemporaryOrgRedirect"; +const mockData = { + redirects: [] as { + toUrl: string; + from: string; + redirectType: RedirectType; + }[], +}; + function mockARedirectInDB({ toUrl, slug, @@ -15,28 +23,38 @@ function mockARedirectInDB({ slug: string; redirectType: RedirectType; }) { + mockData.redirects.push({ + toUrl, + from: slug, + redirectType, + }); // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore - prismaMock.tempOrgRedirect.findUnique.mockImplementation(({ where }) => { + prismaMock.tempOrgRedirect.findMany.mockImplementation(({ where }) => { return new Promise((resolve) => { - if ( - where.from_type_fromOrgId.type === redirectType && - where.from_type_fromOrgId.from === slug && - where.from_type_fromOrgId.fromOrgId === 0 - ) { - resolve({ toUrl }); - } else { - resolve(null); - } + const tempOrgRedirects: typeof mockData.redirects = []; + where.from.in.forEach((whereSlug: string) => { + const matchingRedirect = mockData.redirects.find((redirect) => { + return where.type === redirect.redirectType && whereSlug === redirect.from && where.fromOrgId === 0; + }); + if (matchingRedirect) { + tempOrgRedirects.push(matchingRedirect); + } + }); + resolve(tempOrgRedirects); }); }); } +beforeEach(() => { + mockData.redirects = []; +}); + describe("getTemporaryOrgRedirect", () => { it("should generate event-type URL without existing query params", async () => { mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User }); const redirect = await getTemporaryOrgRedirect({ - slug: "slug", + slugs: "slug", redirectType: RedirectType.User, eventTypeSlug: "30min", currentQuery: {}, @@ -54,7 +72,7 @@ describe("getTemporaryOrgRedirect", () => { mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User }); const redirect = await getTemporaryOrgRedirect({ - slug: "slug", + slugs: "slug", redirectType: RedirectType.User, eventTypeSlug: "30min", currentQuery: { @@ -74,7 +92,7 @@ describe("getTemporaryOrgRedirect", () => { mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User }); const redirect = await getTemporaryOrgRedirect({ - slug: "slug", + slugs: "slug", redirectType: RedirectType.User, eventTypeSlug: null, currentQuery: { @@ -98,7 +116,7 @@ describe("getTemporaryOrgRedirect", () => { }); const redirect = await getTemporaryOrgRedirect({ - slug: "seeded-team", + slugs: "seeded-team", redirectType: RedirectType.Team, eventTypeSlug: null, currentQuery: { @@ -122,7 +140,7 @@ describe("getTemporaryOrgRedirect", () => { }); const redirect = await getTemporaryOrgRedirect({ - slug: "seeded-team", + slugs: "seeded-team", redirectType: RedirectType.Team, eventTypeSlug: "30min", currentQuery: { @@ -146,7 +164,7 @@ describe("getTemporaryOrgRedirect", () => { }); const redirect = await getTemporaryOrgRedirect({ - slug: "seeded-team", + slugs: "seeded-team", redirectType: RedirectType.Team, eventTypeSlug: "30min", currentQuery: {}, @@ -159,4 +177,157 @@ describe("getTemporaryOrgRedirect", () => { }, }); }); + + it("should generate Dynamic Group Booking Profile Url", async () => { + mockARedirectInDB({ + slug: "first", + toUrl: "https://calcom.cal.com/first-in-org1", + redirectType: RedirectType.Team, + }); + mockARedirectInDB({ + slug: "second", + toUrl: "https://calcom.cal.com/second-in-org1", + redirectType: RedirectType.Team, + }); + + const redirect = await getTemporaryOrgRedirect({ + slugs: ["first", "second"], + redirectType: RedirectType.Team, + eventTypeSlug: null, + currentQuery: {}, + }); + + expect(redirect).toEqual({ + redirect: { + permanent: false, + destination: "https://calcom.cal.com/first-in-org1+second-in-org1", + }, + }); + }); + + it("should generate Dynamic Group Booking Profile Url - same order", async () => { + mockARedirectInDB({ + slug: "second", + toUrl: "https://calcom.cal.com/second-in-org1", + redirectType: RedirectType.Team, + }); + mockARedirectInDB({ + slug: "first", + toUrl: "https://calcom.cal.com/first-in-org1", + redirectType: RedirectType.Team, + }); + + const redirect = await getTemporaryOrgRedirect({ + slugs: ["first", "second"], + redirectType: RedirectType.Team, + eventTypeSlug: null, + currentQuery: {}, + }); + + expect(redirect).toEqual({ + redirect: { + permanent: false, + destination: "https://calcom.cal.com/first-in-org1+second-in-org1", + }, + }); + + const redirect1 = await getTemporaryOrgRedirect({ + slugs: ["second", "first"], + redirectType: RedirectType.Team, + eventTypeSlug: null, + currentQuery: {}, + }); + + expect(redirect1).toEqual({ + redirect: { + permanent: false, + destination: "https://calcom.cal.com/second-in-org1+first-in-org1", + }, + }); + }); + + it("should generate Dynamic Group Booking Profile Url with query params", async () => { + mockARedirectInDB({ + slug: "first", + toUrl: "https://calcom.cal.com/first-in-org1", + redirectType: RedirectType.Team, + }); + mockARedirectInDB({ + slug: "second", + toUrl: "https://calcom.cal.com/second-in-org1", + redirectType: RedirectType.Team, + }); + + const redirect = await getTemporaryOrgRedirect({ + slugs: ["first", "second"], + redirectType: RedirectType.Team, + eventTypeSlug: null, + currentQuery: { + abc: "1", + }, + }); + + expect(redirect).toEqual({ + redirect: { + permanent: false, + destination: "https://calcom.cal.com/first-in-org1+second-in-org1?abc=1", + }, + }); + }); + + it("should generate Dynamic Group Booking EventType Url", async () => { + mockARedirectInDB({ + slug: "first", + toUrl: "https://calcom.cal.com/first-in-org1", + redirectType: RedirectType.Team, + }); + mockARedirectInDB({ + slug: "second", + toUrl: "https://calcom.cal.com/second-in-org1", + redirectType: RedirectType.Team, + }); + + const redirect = await getTemporaryOrgRedirect({ + slugs: ["first", "second"], + redirectType: RedirectType.Team, + eventTypeSlug: "30min", + currentQuery: {}, + }); + + expect(redirect).toEqual({ + redirect: { + permanent: false, + destination: "https://calcom.cal.com/first-in-org1+second-in-org1/30min", + }, + }); + }); + + it("should generate Dynamic Group Booking EventType Url with query params", async () => { + mockARedirectInDB({ + slug: "first", + toUrl: "https://calcom.cal.com/first-in-org1", + redirectType: RedirectType.Team, + }); + mockARedirectInDB({ + slug: "second", + toUrl: "https://calcom.cal.com/second-in-org1", + redirectType: RedirectType.Team, + }); + + const redirect = await getTemporaryOrgRedirect({ + slugs: ["first", "second"], + redirectType: RedirectType.Team, + eventTypeSlug: "30min", + currentQuery: { + abc: "1", + }, + }); + + expect(redirect).toEqual({ + redirect: { + permanent: false, + destination: "https://calcom.cal.com/first-in-org1+second-in-org1/30min?abc=1", + }, + }); + }); }); diff --git a/apps/web/lib/getTemporaryOrgRedirect.ts b/apps/web/lib/getTemporaryOrgRedirect.ts index 49010ab848..d9e2c690d0 100644 --- a/apps/web/lib/getTemporaryOrgRedirect.ts +++ b/apps/web/lib/getTemporaryOrgRedirect.ts @@ -7,45 +7,65 @@ import type { RedirectType } from "@calcom/prisma/client"; const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] }); export const getTemporaryOrgRedirect = async ({ - slug, + slugs, redirectType, eventTypeSlug, currentQuery, }: { - slug: string; + slugs: string[] | string; redirectType: RedirectType; eventTypeSlug: string | null; currentQuery: ParsedUrlQuery; }) => { const prisma = (await import("@calcom/prisma")).default; + slugs = slugs instanceof Array ? slugs : [slugs]; log.debug( `Looking for redirect for`, safeStringify({ - slug, + slugs, redirectType, eventTypeSlug, }) ); - const redirect = await prisma.tempOrgRedirect.findUnique({ + + const redirects = await prisma.tempOrgRedirect.findMany({ where: { - from_type_fromOrgId: { - type: redirectType, - from: slug, - fromOrgId: 0, + type: redirectType, + from: { + in: slugs, }, + fromOrgId: 0, }, }); - if (redirect) { - log.debug(`Redirecting ${slug} to ${redirect.toUrl}`); - const newDestinationWithoutQuery = eventTypeSlug ? `${redirect.toUrl}/${eventTypeSlug}` : redirect.toUrl; - const currentQueryString = stringify(currentQuery); - return { - redirect: { - permanent: false, - destination: `${newDestinationWithoutQuery}${currentQueryString ? `?${currentQueryString}` : ""}`, - }, - } as const; + const currentQueryString = stringify(currentQuery); + if (!redirects.length) { + return null; } - return null; + + // Use the first redirect origin as the new origin as we aren't supposed to handle different org usernames in a group + const newOrigin = new URL(redirects[0].toUrl).origin; + const query = currentQueryString ? `?${currentQueryString}` : ""; + // Use the same order as in input slugs - It is important from Dynamic Group perspective as the first user's settings are used for various things + const newSlugs = slugs.map((slug) => { + const redirect = redirects.find((redirect) => redirect.from === slug); + if (!redirect) { + return slug; + } + const newSlug = new URL(redirect.toUrl).pathname.slice(1); + return newSlug; + }); + + const newSlug = newSlugs.join("+"); + const newPath = newSlug ? `/${newSlug}` : ""; + + const newDestination = `${newOrigin}${newPath}${eventTypeSlug ? `/${eventTypeSlug}` : ""}${query}`; + log.debug(`Suggesting redirect from ${slugs} to ${newDestination}`); + + return { + redirect: { + permanent: false, + destination: newDestination, + }, + } as const; }; diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index 5c8b506074..6a274115cc 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -318,7 +318,7 @@ export const getServerSideProps: GetServerSideProps = async (cont const isOrgContext = isValidOrgDomain && currentOrgDomain; const dataFetchStart = Date.now(); let outOfOffice = false; - + const isDynamicGroup = usernameList.length > 1; if (usernameList.length === 1) { const result = await handleUserRedirection({ username: usernameList[0] }); if (result && result.outOfOffice) { @@ -329,6 +329,19 @@ export const getServerSideProps: GetServerSideProps = async (cont } } + if (!isOrgContext) { + const redirect = await getTemporaryOrgRedirect({ + slugs: usernameList, + redirectType: RedirectType.User, + eventTypeSlug: null, + currentQuery: context.query, + }); + + if (redirect) { + return redirect; + } + } + const usersWithoutAvatar = await prisma.user.findMany({ where: { username: { @@ -362,21 +375,6 @@ export const getServerSideProps: GetServerSideProps = async (cont }, }); - const isDynamicGroup = usersWithoutAvatar.length > 1; - if (isDynamicGroup) { - return { - redirect: { - permanent: false, - destination: `/${usernameList.join("+")}/dynamic`, - }, - } as { - redirect: { - permanent: false; - destination: string; - }; - }; - } - const users = usersWithoutAvatar.map((user) => ({ ...user, organization: { @@ -386,17 +384,20 @@ export const getServerSideProps: GetServerSideProps = async (cont avatar: `/${user.username}/avatar.png`, })); - if (!isOrgContext) { - const redirect = await getTemporaryOrgRedirect({ - slug: usernameList[0], - redirectType: RedirectType.User, - eventTypeSlug: null, - currentQuery: context.query, - }); - - if (redirect) { - return redirect; - } + if (isDynamicGroup) { + const destinationUrl = `/${usernameList.join("+")}/dynamic`; + logger.debug(`Dynamic group detected, redirecting to ${destinationUrl}`); + return { + redirect: { + permanent: false, + destination: destinationUrl, + }, + } as { + redirect: { + permanent: false; + destination: string; + }; + }; } if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) { diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx index f36ecdc15b..db7312154f 100644 --- a/apps/web/pages/[user]/[type].tsx +++ b/apps/web/pages/[user]/[type].tsx @@ -90,6 +90,19 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) { const { ssrInit } = await import("@server/lib/ssr"); const ssr = await ssrInit(context); const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug); + const org = isValidOrgDomain ? currentOrgDomain : null; + if (!org) { + const redirect = await getTemporaryOrgRedirect({ + slugs: usernames, + redirectType: RedirectType.User, + eventTypeSlug: slug, + currentQuery: context.query, + }); + + if (redirect) { + return redirect; + } + } const users = await prisma.user.findMany({ where: { @@ -112,7 +125,6 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) { notFound: true, } as const; } - const org = isValidOrgDomain ? currentOrgDomain : null; let booking: GetBookingType | null = null; if (rescheduleUid) { @@ -167,10 +179,9 @@ async function getUserPageProps(context: GetServerSidePropsContext) { const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug); let outOfOffice = false; const isOrgContext = currentOrgDomain && isValidOrgDomain; - if (!isOrgContext) { const redirect = await getTemporaryOrgRedirect({ - slug: usernames[0], + slugs: usernames, redirectType: RedirectType.User, eventTypeSlug: slug, currentQuery: context.query, diff --git a/apps/web/pages/team/[slug].tsx b/apps/web/pages/team/[slug].tsx index a5cdaca7fa..6b80836974 100644 --- a/apps/web/pages/team/[slug].tsx +++ b/apps/web/pages/team/[slug].tsx @@ -294,7 +294,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => if (!isOrgContext && slug) { const redirect = await getTemporaryOrgRedirect({ - slug: slug, + slugs: slug, redirectType: RedirectType.Team, eventTypeSlug: null, currentQuery: context.query, diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index 781f17d4b8..0ae240b95b 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -81,7 +81,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => if (!isOrgContext) { const redirect = await getTemporaryOrgRedirect({ - slug: teamSlug, + slugs: teamSlug, redirectType: RedirectType.Team, eventTypeSlug: meetingSlug, currentQuery: context.query, diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts index 558bfb4e20..7a58b927fc 100644 --- a/apps/web/playwright/teams.e2e.ts +++ b/apps/web/playwright/teams.e2e.ts @@ -268,11 +268,13 @@ test.describe("Teams - NonOrg", () => { // Mark team as private await page.goto(`/settings/teams/${team.id}/members`); - await page.click("[data-testid=make-team-private-check]"); - await expect(page.locator(`[data-testid=make-team-private-check][data-state="checked"]`)).toBeVisible(); - // according to switch implementation, checked state can be set before mutation is resolved - // so we need to await for req to resolve - await page.waitForResponse((res) => res.url().includes("/api/trpc/teams/update")); + await Promise.all([ + page.click("[data-testid=make-team-private-check]"), + expect(page.locator(`[data-testid=make-team-private-check][data-state="checked"]`)).toBeVisible(), + // according to switch implementation, checked state can be set before mutation is resolved + // so we need to await for req to resolve + page.waitForResponse((res) => res.url().includes("/api/trpc/teams/update")), + ]); // Go to Team's page await page.goto(`/team/${team.slug}`);