Merge branch 'main' into feat/single-event
commit
24cb26e43f
|
@ -15,3 +15,5 @@ jobs:
|
|||
- uses: ./.github/actions/yarn-install
|
||||
# Should be an 8GB machine as per https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners
|
||||
- run: yarn test
|
||||
# We could add different timezones here that we need to run our tests in
|
||||
- run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
diff --git a/index.cjs b/index.cjs
|
||||
index b645707a3549fc298508726e404243499bbed499..f34b0891e99b275a9218e253f303f43d31ef3f73 100644
|
||||
--- a/index.cjs
|
||||
+++ b/index.cjs
|
||||
@@ -13,8 +13,8 @@ function withMetadataArgument(func, _arguments) {
|
||||
// https://github.com/babel/babel/issues/2212#issuecomment-131827986
|
||||
// An alternative approach:
|
||||
// https://www.npmjs.com/package/babel-plugin-add-module-exports
|
||||
-exports = module.exports = min.parsePhoneNumberFromString
|
||||
-exports['default'] = min.parsePhoneNumberFromString
|
||||
+// exports = module.exports = min.parsePhoneNumberFromString
|
||||
+// exports['default'] = min.parsePhoneNumberFromString
|
||||
|
||||
// `parsePhoneNumberFromString()` named export is now considered legacy:
|
||||
// it has been promoted to a default export due to being too verbose.
|
|
@ -0,0 +1,26 @@
|
|||
diff --git a/dist/commonjs/serverSideTranslations.js b/dist/commonjs/serverSideTranslations.js
|
||||
index bcad3d02fbdfab8dacb1d85efd79e98623a0c257..fff668f598154a13c4030d1b4a90d5d9c18214ad 100644
|
||||
--- a/dist/commonjs/serverSideTranslations.js
|
||||
+++ b/dist/commonjs/serverSideTranslations.js
|
||||
@@ -36,7 +36,6 @@ var _fs = _interopRequireDefault(require("fs"));
|
||||
var _path = _interopRequireDefault(require("path"));
|
||||
var _createConfig = require("./config/createConfig");
|
||||
var _node = _interopRequireDefault(require("./createClient/node"));
|
||||
-var _appWithTranslation = require("./appWithTranslation");
|
||||
var _utils = require("./utils");
|
||||
function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
|
||||
function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { (0, _defineProperty2["default"])(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
|
||||
@@ -110,12 +109,8 @@ var serverSideTranslations = /*#__PURE__*/function () {
|
||||
lng: initialLocale
|
||||
}));
|
||||
localeExtension = config.localeExtension, localePath = config.localePath, fallbackLng = config.fallbackLng, reloadOnPrerender = config.reloadOnPrerender;
|
||||
- if (!reloadOnPrerender) {
|
||||
- _context.next = 18;
|
||||
- break;
|
||||
- }
|
||||
_context.next = 18;
|
||||
- return _appWithTranslation.globalI18n === null || _appWithTranslation.globalI18n === void 0 ? void 0 : _appWithTranslation.globalI18n.reloadResources();
|
||||
+ return void 0;
|
||||
case 18:
|
||||
_createClient = (0, _node["default"])(_objectSpread(_objectSpread({}, config), {}, {
|
||||
lng: initialLocale
|
|
@ -0,0 +1,109 @@
|
|||
import type { Metadata } from "next";
|
||||
import { headers as nextHeaders, cookies as nextCookies } from "next/headers";
|
||||
import Script from "next/script";
|
||||
import React from "react";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
import "../styles/globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
sizes: "32x32",
|
||||
url: "/api/logo?type=favicon-32",
|
||||
},
|
||||
{
|
||||
sizes: "16x16",
|
||||
url: "/api/logo?type=favicon-16",
|
||||
},
|
||||
],
|
||||
apple: {
|
||||
sizes: "180x180",
|
||||
url: "/api/logo?type=apple-touch-icon",
|
||||
},
|
||||
other: [
|
||||
{
|
||||
url: "/safari-pinned-tab.svg",
|
||||
rel: "mask-icon",
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: "/site.webmanifest",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#f9fafb" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#1C1C1C" },
|
||||
],
|
||||
other: {
|
||||
"msapplication-TileColor": "#000000",
|
||||
},
|
||||
};
|
||||
|
||||
const getInitialProps = async (
|
||||
url: string,
|
||||
headers: ReturnType<typeof nextHeaders>,
|
||||
cookies: ReturnType<typeof nextCookies>
|
||||
) => {
|
||||
const { pathname, searchParams } = new URL(url);
|
||||
|
||||
const isEmbed = pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null;
|
||||
const embedColorScheme = searchParams?.get("ui.color-scheme");
|
||||
|
||||
// @ts-expect-error we cannot access ctx.req in app dir, however headers and cookies are only properties needed to extract the locale
|
||||
const newLocale = await getLocale({ headers, cookies });
|
||||
let direction = "ltr";
|
||||
|
||||
try {
|
||||
const intlLocale = new Intl.Locale(newLocale);
|
||||
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
|
||||
direction = intlLocale.textInfo?.direction;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
return { isEmbed, embedColorScheme, locale: newLocale, direction };
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const headers = nextHeaders();
|
||||
const cookies = nextCookies();
|
||||
|
||||
const fullUrl = headers.get("x-url") ?? "";
|
||||
const nonce = headers.get("x-csp") ?? "";
|
||||
|
||||
const { locale, direction, isEmbed, embedColorScheme } = await getInitialProps(fullUrl, headers, cookies);
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
dir={direction}
|
||||
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
||||
<head nonce={nonce}>
|
||||
{!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && (
|
||||
// eslint-disable-next-line @next/next/no-sync-scripts
|
||||
<Script
|
||||
data-project-id="KjpMrKTnXquJVKfeqmjdTffVPf1a6Unw2LZ58iE4"
|
||||
src="https://snippet.meticulous.ai/v1/stagingMeticulousSnippet.js"
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body
|
||||
className="dark:bg-darkgray-50 desktop-transparent bg-subtle antialiased"
|
||||
style={
|
||||
isEmbed
|
||||
? {
|
||||
background: "transparent",
|
||||
// Keep the embed hidden till parent initializes and
|
||||
// - gives it the appropriate styles if UI instruction is there.
|
||||
// - gives iframe the appropriate height(equal to document height) which can only be known after loading the page once in browser.
|
||||
// - Tells iframe which mode it should be in (dark/light) - if there is a a UI instruction for that
|
||||
visibility: "hidden",
|
||||
}
|
||||
: {}
|
||||
}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
import type { SSRConfig } from "next-i18next";
|
||||
import { Inter } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
// import I18nLanguageHandler from "@components/I18nLanguageHandler";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Script from "next/script";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers-app-dir";
|
||||
import AppProviders from "@lib/app-providers-app-dir";
|
||||
|
||||
export interface CalPageWrapper {
|
||||
(props?: AppProps): JSX.Element;
|
||||
PageWrapper?: AppProps["Component"]["PageWrapper"];
|
||||
}
|
||||
|
||||
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
|
||||
const calFont = localFont({
|
||||
src: "../fonts/CalSans-SemiBold.woff2",
|
||||
variable: "--font-cal",
|
||||
preload: true,
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export type PageWrapperProps = Readonly<{
|
||||
getLayout: (page: React.ReactElement) => ReactNode;
|
||||
children: React.ReactElement;
|
||||
requiresLicense: boolean;
|
||||
isThemeSupported: boolean;
|
||||
isBookingPage: boolean;
|
||||
nonce: string | undefined;
|
||||
themeBasis: string | null;
|
||||
i18n?: SSRConfig;
|
||||
}>;
|
||||
|
||||
function PageWrapper(props: PageWrapperProps) {
|
||||
const pathname = usePathname();
|
||||
let pageStatus = "200";
|
||||
|
||||
if (pathname === "/404") {
|
||||
pageStatus = "404";
|
||||
} else if (pathname === "/500") {
|
||||
pageStatus = "500";
|
||||
}
|
||||
|
||||
// On client side don't let nonce creep into DOM
|
||||
// It also avoids hydration warning that says that Client has the nonce value but server has "" because browser removes nonce attributes before DOM is built
|
||||
// See https://github.com/kentcdodds/nonce-hydration-issues
|
||||
// Set "" only if server had it set otherwise keep it undefined because server has to match with client to avoid hydration error
|
||||
const nonce = typeof window !== "undefined" ? (props.nonce ? "" : undefined) : props.nonce;
|
||||
const providerProps: PageWrapperProps = {
|
||||
...props,
|
||||
nonce,
|
||||
};
|
||||
|
||||
const getLayout: (page: React.ReactElement) => ReactNode = props.getLayout ?? ((page) => page);
|
||||
|
||||
return (
|
||||
<AppProviders {...providerProps}>
|
||||
{/* <I18nLanguageHandler locales={props.router.locales || []} /> */}
|
||||
<>
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="page-status"
|
||||
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
|
||||
/>
|
||||
<style jsx global>{`
|
||||
:root {
|
||||
--font-inter: ${interFont.style.fontFamily};
|
||||
--font-cal: ${calFont.style.fontFamily};
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{getLayout(
|
||||
props.requiresLicense ? <LicenseRequired>{props.children}</LicenseRequired> : props.children
|
||||
)}
|
||||
</>
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
export default trpc.withTRPC(PageWrapper);
|
|
@ -433,6 +433,23 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
|||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="lockTimeZoneToggleOnBookingPage"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.lockTimeZoneToggleOnBookingPage}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
|
||||
title={t("lock_timezone_toggle_on_booking_page")}
|
||||
{...shouldLockDisableProps("lockTimeZoneToggleOnBookingPage")}
|
||||
description={t("description_lock_timezone_toggle_on_booking_page")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{allowDisablingAttendeeConfirmationEmails(workflows) && (
|
||||
<>
|
||||
<Controller
|
||||
|
|
|
@ -0,0 +1,291 @@
|
|||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { dir } from "i18next";
|
||||
import type { Session } from "next-auth";
|
||||
import { SessionProvider, useSession } from "next-auth/react";
|
||||
import { EventCollectionProvider } from "next-collect/client";
|
||||
import { appWithTranslation, type SSRConfig } from "next-i18next";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import type { AppProps as NextAppProps } from "next/app";
|
||||
import type { ReadonlyURLSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
||||
import { FeatureProvider } from "@calcom/features/flags/context/provider";
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { MetaProvider } from "@calcom/ui";
|
||||
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import type { WithNonceProps } from "@lib/withNonce";
|
||||
|
||||
import { useViewerI18n } from "@components/I18nLanguageHandler";
|
||||
import type { PageWrapperProps } from "@components/PageWrapperAppDir";
|
||||
|
||||
// Workaround for https://github.com/vercel/next.js/issues/8592
|
||||
export type AppProps = Omit<
|
||||
NextAppProps<
|
||||
WithNonceProps<{
|
||||
themeBasis?: string;
|
||||
session: Session;
|
||||
}>
|
||||
>,
|
||||
"Component"
|
||||
> & {
|
||||
Component: NextAppProps["Component"] & {
|
||||
requiresLicense?: boolean;
|
||||
isThemeSupported?: boolean;
|
||||
isBookingPage?: boolean | ((arg: { router: NextAppProps["router"] }) => boolean);
|
||||
getLayout?: (page: React.ReactElement) => ReactNode;
|
||||
PageWrapper?: (props: AppProps) => JSX.Element;
|
||||
};
|
||||
|
||||
/** Will be defined only is there was an error */
|
||||
err?: Error;
|
||||
};
|
||||
|
||||
const getEmbedNamespace = (searchParams: ReadonlyURLSearchParams) => {
|
||||
// Mostly embed query param should be available on server. Use that there.
|
||||
// Use the most reliable detection on client
|
||||
return typeof window !== "undefined" ? window.getEmbedNamespace() : searchParams.get("embed") ?? null;
|
||||
};
|
||||
|
||||
// @ts-expect-error appWithTranslation expects AppProps
|
||||
const AppWithTranslationHoc = appWithTranslation(({ children }) => <>{children}</>);
|
||||
|
||||
const CustomI18nextProvider = (props: { children: React.ReactElement; i18n?: SSRConfig }) => {
|
||||
/**
|
||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||
**/
|
||||
// @TODO
|
||||
|
||||
const session = useSession();
|
||||
const locale =
|
||||
session?.data?.user.locale ?? typeof window !== "undefined" ? window.document.documentElement.lang : "en";
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
// @ts-expect-error TS2790: The operand of a 'delete' operator must be optional.
|
||||
delete window.document.documentElement["lang"];
|
||||
|
||||
window.document.documentElement.lang = locale;
|
||||
|
||||
// Next.js writes the locale to the same attribute
|
||||
// https://github.com/vercel/next.js/blob/1609da2d9552fed48ab45969bdc5631230c6d356/packages/next/src/shared/lib/router/router.ts#L1786
|
||||
// which can result in a race condition
|
||||
// this property descriptor ensures this never happens
|
||||
Object.defineProperty(window.document.documentElement, "lang", {
|
||||
configurable: true,
|
||||
// value: locale,
|
||||
set: function (this) {
|
||||
// empty setter on purpose
|
||||
},
|
||||
get: function () {
|
||||
return locale;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
window.document.documentElement.lang = locale;
|
||||
}
|
||||
window.document.dir = dir(locale);
|
||||
}, [locale]);
|
||||
|
||||
const clientViewerI18n = useViewerI18n(locale);
|
||||
const i18n = clientViewerI18n.data?.i18n ?? props.i18n;
|
||||
|
||||
if (!i18n || !i18n._nextI18Next) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
// @ts-expect-error AppWithTranslationHoc expects AppProps
|
||||
<AppWithTranslationHoc pageProps={{ _nextI18Next: i18n._nextI18Next }}>
|
||||
{props.children}
|
||||
</AppWithTranslationHoc>
|
||||
);
|
||||
};
|
||||
|
||||
const enum ThemeSupport {
|
||||
// e.g. Login Page
|
||||
None = "none",
|
||||
// Entire App except Booking Pages
|
||||
App = "systemOnly",
|
||||
// Booking Pages(including Routing Forms)
|
||||
Booking = "userConfigured",
|
||||
}
|
||||
|
||||
type CalcomThemeProps = Readonly<{
|
||||
isBookingPage: boolean;
|
||||
themeBasis: string | null;
|
||||
nonce: string | undefined;
|
||||
isThemeSupported: boolean;
|
||||
children: React.ReactNode;
|
||||
}>;
|
||||
|
||||
const CalcomThemeProvider = (props: CalcomThemeProps) => {
|
||||
// Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently
|
||||
// One such example is our Embeds Demo and Testing page at http://localhost:3100
|
||||
// Having `getEmbedNamespace` defined on window before react initializes the app, ensures that embedNamespace is available on the first mount and can be used as part of storageKey
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const embedNamespace = searchParams ? getEmbedNamespace(searchParams) : null;
|
||||
const isEmbedMode = typeof embedNamespace === "string";
|
||||
|
||||
return (
|
||||
<ThemeProvider {...getThemeProviderProps({ ...props, isEmbedMode, embedNamespace })}>
|
||||
{/* Embed Mode can be detected reliably only on client side here as there can be static generated pages as well which can't determine if it's embed mode at backend */}
|
||||
{/* color-scheme makes background:transparent not work in iframe which is required by embed. */}
|
||||
{typeof window !== "undefined" && !isEmbedMode && (
|
||||
<style jsx global>
|
||||
{`
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
)}
|
||||
{props.children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The most important job for this fn is to generate correct storageKey for theme persistenc.
|
||||
* `storageKey` is important because that key is listened for changes(using [`storage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) event) and any pages opened will change it's theme based on that(as part of next-themes implementation).
|
||||
* Choosing the right storageKey avoids theme flickering caused by another page using different theme
|
||||
* So, we handle all the cases here namely,
|
||||
* - Both Booking Pages, /free/30min and /pro/30min but configured with different themes but being operated together.
|
||||
* - Embeds using different namespace. They can be completely themed different on the same page.
|
||||
* - Embeds using the same namespace but showing different cal.com links with different themes
|
||||
* - Embeds using the same namespace and showing same cal.com links with different themes(Different theme is possible for same cal.com link in case of embed because of theme config available in embed)
|
||||
* - App has different theme then Booking Pages.
|
||||
*
|
||||
* All the above cases have one thing in common, which is the origin and thus localStorage is shared and thus `storageKey` is critical to avoid theme flickering.
|
||||
*
|
||||
* Some things to note:
|
||||
* - There is a side effect of so many factors in `storageKey` that many localStorage keys will be created if a user goes through all these scenarios(e.g like booking a lot of different users)
|
||||
* - Some might recommend disabling localStorage persistence but that doesn't give good UX as then we would default to light theme always for a few seconds before switching to dark theme(if that's the user's preference).
|
||||
* - We can't disable [`storage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) event handling as well because changing theme in one tab won't change the theme without refresh in other tabs. That's again a bad UX
|
||||
* - Theme flickering becomes infinitely ongoing in case of embeds because of the browser's delay in processing `storage` event within iframes. Consider two embeds simulatenously opened with pages A and B. Note the timeline and keep in mind that it happened
|
||||
* because 'setItem(A)' and 'Receives storageEvent(A)' allowed executing setItem(B) in b/w because of the delay.
|
||||
* - t1 -> setItem(A) & Fires storageEvent(A) - On Page A) - Current State(A)
|
||||
* - t2 -> setItem(B) & Fires storageEvent(B) - On Page B) - Current State(B)
|
||||
* - t3 -> Receives storageEvent(A) & thus setItem(A) & thus fires storageEvent(A) (On Page B) - Current State(A)
|
||||
* - t4 -> Receives storageEvent(B) & thus setItem(B) & thus fires storageEvent(B) (On Page A) - Current State(B)
|
||||
* - ... and so on ...
|
||||
*/
|
||||
function getThemeProviderProps(props: {
|
||||
isBookingPage: boolean;
|
||||
themeBasis: string | null;
|
||||
nonce: string | undefined;
|
||||
isEmbedMode: boolean;
|
||||
embedNamespace: string | null;
|
||||
isThemeSupported: boolean;
|
||||
}) {
|
||||
const themeSupport = props.isBookingPage
|
||||
? ThemeSupport.Booking
|
||||
: // if isThemeSupported is explicitly false, we don't use theme there
|
||||
props.isThemeSupported === false
|
||||
? ThemeSupport.None
|
||||
: ThemeSupport.App;
|
||||
|
||||
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
|
||||
|
||||
if ((isBookingPageThemeSupportRequired || props.isEmbedMode) && !props.themeBasis) {
|
||||
console.warn(
|
||||
"`themeBasis` is required for booking page theme support. Not providing it will cause theme flicker."
|
||||
);
|
||||
}
|
||||
|
||||
const appearanceIdSuffix = props.themeBasis ? `:${props.themeBasis}` : "";
|
||||
const forcedTheme = themeSupport === ThemeSupport.None ? "light" : undefined;
|
||||
let embedExplicitlySetThemeSuffix = "";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const embedTheme = window.getEmbedTheme();
|
||||
if (embedTheme) {
|
||||
embedExplicitlySetThemeSuffix = `:${embedTheme}`;
|
||||
}
|
||||
}
|
||||
|
||||
const storageKey = props.isEmbedMode
|
||||
? // Same Namespace, Same Organizer but different themes would still work seamless and not cause theme flicker
|
||||
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
|
||||
`embed-theme-${props.embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
|
||||
: themeSupport === ThemeSupport.App
|
||||
? "app-theme"
|
||||
: isBookingPageThemeSupportRequired
|
||||
? `booking-theme${appearanceIdSuffix}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
storageKey,
|
||||
forcedTheme,
|
||||
themeSupport,
|
||||
nonce: props.nonce,
|
||||
enableColorScheme: false,
|
||||
enableSystem: themeSupport !== ThemeSupport.None,
|
||||
// next-themes doesn't listen to changes on storageKey. So we need to force a re-render when storageKey changes
|
||||
// This is how login to dashboard soft navigation changes theme from light to dark
|
||||
key: storageKey,
|
||||
attribute: "class",
|
||||
};
|
||||
}
|
||||
|
||||
function FeatureFlagsProvider({ children }: { children: React.ReactNode }) {
|
||||
const flags = useFlags();
|
||||
return <FeatureProvider value={flags}>{children}</FeatureProvider>;
|
||||
}
|
||||
|
||||
function useOrgBrandingValues() {
|
||||
const session = useSession();
|
||||
return session?.data?.user.org;
|
||||
}
|
||||
|
||||
function OrgBrandProvider({ children }: { children: React.ReactNode }) {
|
||||
const orgBrand = useOrgBrandingValues();
|
||||
return <OrgBrandingProvider value={{ orgBrand }}>{children}</OrgBrandingProvider>;
|
||||
}
|
||||
|
||||
const AppProviders = (props: PageWrapperProps) => {
|
||||
// No need to have intercom on public pages - Good for Page Performance
|
||||
const isBookingPage = useIsBookingPage();
|
||||
|
||||
const RemainingProviders = (
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
<SessionProvider>
|
||||
<CustomI18nextProvider i18n={props.i18n}>
|
||||
<TooltipProvider>
|
||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||
<CalcomThemeProvider
|
||||
themeBasis={props.themeBasis}
|
||||
nonce={props.nonce}
|
||||
isThemeSupported={props.isThemeSupported}
|
||||
isBookingPage={props.isBookingPage || isBookingPage}>
|
||||
<FeatureFlagsProvider>
|
||||
<OrgBrandProvider>
|
||||
<MetaProvider>{props.children}</MetaProvider>
|
||||
</OrgBrandProvider>
|
||||
</FeatureFlagsProvider>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
</CustomI18nextProvider>
|
||||
</SessionProvider>
|
||||
</EventCollectionProvider>
|
||||
);
|
||||
|
||||
if (isBookingPage) {
|
||||
return RemainingProviders;
|
||||
}
|
||||
|
||||
return (
|
||||
<DynamicHelpscoutProvider>
|
||||
<DynamicIntercomProvider>{RemainingProviders}</DynamicIntercomProvider>
|
||||
</DynamicHelpscoutProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppProviders;
|
|
@ -0,0 +1,162 @@
|
|||
import prismaMock from "../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "./getTemporaryOrgRedirect";
|
||||
|
||||
function mockARedirectInDB({
|
||||
toUrl,
|
||||
slug,
|
||||
redirectType,
|
||||
}: {
|
||||
toUrl: string;
|
||||
slug: string;
|
||||
redirectType: RedirectType;
|
||||
}) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
prismaMock.tempOrgRedirect.findUnique.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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate event-type URL with existing query params", async () => {
|
||||
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "slug",
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min?abc=1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate User URL with existing query params", async () => {
|
||||
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "slug",
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com?abc=1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Team Profile URL with existing query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "seeded-team",
|
||||
toUrl: "https://calcom.cal.com",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "seeded-team",
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com?abc=1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Team Event URL with existing query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "seeded-team",
|
||||
toUrl: "https://calcom.cal.com",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "seeded-team",
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min?abc=1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Team Event URL without query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "seeded-team",
|
||||
toUrl: "https://calcom.cal.com",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "seeded-team",
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,3 +1,6 @@
|
|||
import type { ParsedUrlQuery } from "querystring";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { RedirectType } from "@calcom/prisma/client";
|
||||
|
@ -7,10 +10,12 @@ export const getTemporaryOrgRedirect = async ({
|
|||
slug,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
currentQuery,
|
||||
}: {
|
||||
slug: string;
|
||||
redirectType: RedirectType;
|
||||
eventTypeSlug: string | null;
|
||||
currentQuery: ParsedUrlQuery;
|
||||
}) => {
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
log.debug(
|
||||
|
@ -33,10 +38,12 @@ export const getTemporaryOrgRedirect = async ({
|
|||
|
||||
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: eventTypeSlug ? `${redirect.toUrl}/${eventTypeSlug}` : redirect.toUrl,
|
||||
destination: `${newDestinationWithoutQuery}${currentQueryString ? `?${currentQueryString}` : ""}`,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,258 @@
|
|||
import type { Request, Response } from "express";
|
||||
import type { Redirect } from "next";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import withEmbedSsr from "./withEmbedSsr";
|
||||
|
||||
export type CustomNextApiRequest = NextApiRequest & Request;
|
||||
|
||||
export type CustomNextApiResponse = NextApiResponse & Response;
|
||||
export function createMockNextJsRequest(...args: Parameters<typeof createMocks>) {
|
||||
return createMocks<CustomNextApiRequest, CustomNextApiResponse>(...args);
|
||||
}
|
||||
|
||||
function getServerSidePropsFnGenerator(
|
||||
config:
|
||||
| { redirectUrl: string }
|
||||
| { props: Record<string, unknown> }
|
||||
| {
|
||||
notFound: true;
|
||||
}
|
||||
) {
|
||||
if ("redirectUrl" in config)
|
||||
return async () => {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: config.redirectUrl,
|
||||
} satisfies Redirect,
|
||||
};
|
||||
};
|
||||
|
||||
if ("props" in config)
|
||||
return async () => {
|
||||
return {
|
||||
props: config.props,
|
||||
};
|
||||
};
|
||||
|
||||
if ("notFound" in config)
|
||||
return async () => {
|
||||
return {
|
||||
notFound: true as const,
|
||||
};
|
||||
};
|
||||
|
||||
throw new Error("Invalid config");
|
||||
}
|
||||
|
||||
function getServerSidePropsContextArg({
|
||||
embedRelatedParams,
|
||||
}: {
|
||||
embedRelatedParams?: Record<string, string>;
|
||||
}) {
|
||||
return {
|
||||
...createMockNextJsRequest(),
|
||||
query: {
|
||||
...embedRelatedParams,
|
||||
},
|
||||
resolvedUrl: "/MOCKED_RESOLVED_URL",
|
||||
};
|
||||
}
|
||||
|
||||
describe("withEmbedSsr", () => {
|
||||
describe("when gSSP returns redirect", () => {
|
||||
describe("when redirect destination is relative, should add /embed to end of the path", () => {
|
||||
it("should add layout and embed params from the current query", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "/reschedule",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "namespace1",
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
destination: "/reschedule/embed?layout=week_view&embed=namespace1",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should add layout and embed params without losing query params that were in redirect", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "/reschedule?redirectParam=1",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "namespace1",
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
destination: "/reschedule/embed?redirectParam=1&layout=week_view&embed=namespace1",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should add embed param even when it was empty(i.e. default namespace of embed)", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "/reschedule?redirectParam=1",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "",
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
destination: "/reschedule/embed?redirectParam=1&layout=week_view&embed=",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when redirect destination is absolute, should add /embed to end of the path", () => {
|
||||
it("should add layout and embed params from the current query when destination URL is HTTPS", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "https://calcom.cal.local/owner",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "namespace1",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
destination: "https://calcom.cal.local/owner/embed?layout=week_view&embed=namespace1",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
it("should add layout and embed params from the current query when destination URL is HTTP", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "http://calcom.cal.local/owner",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "namespace1",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
destination: "http://calcom.cal.local/owner/embed?layout=week_view&embed=namespace1",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
it("should correctly identify a URL as non absolute URL if protocol is missing", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "httpcalcom.cal.local/owner",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "namespace1",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
// FIXME: Note that it is adding a / in the beginning of the path, which might be fine for now, but could be an issue
|
||||
destination: "/httpcalcom.cal.local/owner/embed?layout=week_view&embed=namespace1",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when gSSP returns props", () => {
|
||||
it("should add isEmbed=true prop", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
props: {
|
||||
prop1: "value1",
|
||||
},
|
||||
})
|
||||
);
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "",
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ret).toEqual({
|
||||
props: {
|
||||
prop1: "value1",
|
||||
isEmbed: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when gSSP doesn't have props or redirect ", () => {
|
||||
it("should return the result from gSSP as is", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
notFound: true,
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(ret).toEqual({ notFound: true });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,5 +1,7 @@
|
|||
import type { GetServerSideProps, GetServerSidePropsContext, GetServerSidePropsResult } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
export type EmbedProps = {
|
||||
isEmbed?: boolean;
|
||||
};
|
||||
|
@ -11,14 +13,25 @@ export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
|
|||
const layout = context.query.layout;
|
||||
|
||||
if ("redirect" in ssrResponse) {
|
||||
// Use a dummy URL https://base as the fallback base URL so that URL parsing works for relative URLs as well.
|
||||
const destinationUrlObj = new URL(ssrResponse.redirect.destination, "https://base");
|
||||
const destinationUrl = ssrResponse.redirect.destination;
|
||||
let urlPrefix = "";
|
||||
|
||||
// Get the URL parsed from URL so that we can reliably read pathname and searchParams from it.
|
||||
const destinationUrlObj = new URL(ssrResponse.redirect.destination, WEBAPP_URL);
|
||||
|
||||
// If it's a complete URL, use the origin as the prefix to ensure we redirect to the same domain.
|
||||
if (destinationUrl.search(/^(http:|https:).*/) !== -1) {
|
||||
urlPrefix = destinationUrlObj.origin;
|
||||
} else {
|
||||
// Don't use any prefix for relative URLs to ensure we stay on the same domain
|
||||
urlPrefix = "";
|
||||
}
|
||||
|
||||
const destinationQueryStr = destinationUrlObj.searchParams.toString();
|
||||
// Make sure that redirect happens to /embed page and pass on embed query param as is for preserving Cal JS API namespace
|
||||
const newDestinationUrl = `${
|
||||
destinationUrlObj.pathname
|
||||
}/embed?${destinationUrlObj.searchParams.toString()}&layout=${layout}&embed=${embed}`;
|
||||
|
||||
const newDestinationUrl = `${urlPrefix}${destinationUrlObj.pathname}/embed?${
|
||||
destinationQueryStr ? `${destinationQueryStr}&` : ""
|
||||
}layout=${layout}&embed=${embed}`;
|
||||
return {
|
||||
...ssrResponse,
|
||||
redirect: {
|
||||
|
|
|
@ -226,6 +226,14 @@ const nextConfig = {
|
|||
},
|
||||
async rewrites() {
|
||||
const beforeFiles = [
|
||||
{
|
||||
/**
|
||||
* Needed due to the introduction of dotted usernames
|
||||
* @see https://github.com/calcom/cal.com/pull/11706
|
||||
*/
|
||||
source: "/embed.js",
|
||||
destination: "/embed/embed.js",
|
||||
},
|
||||
{
|
||||
source: "/login",
|
||||
destination: "/auth/login",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.4.3",
|
||||
"version": "3.4.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
|
|
@ -3,7 +3,10 @@ import Link from "next/link";
|
|||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { orgDomainConfig, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import {
|
||||
getOrgDomainConfigFromHostname,
|
||||
subdomainSuffix,
|
||||
} from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { DOCS_URL, IS_CALCOM, JOIN_DISCORD, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
|
@ -50,7 +53,10 @@ export default function Custom404() {
|
|||
|
||||
const [url, setUrl] = useState(`${WEBSITE_URL}/signup`);
|
||||
useEffect(() => {
|
||||
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(window.location.host);
|
||||
const { isValidOrgDomain, currentOrgDomain } = getOrgDomainConfigFromHostname({
|
||||
hostname: window.location.host,
|
||||
});
|
||||
|
||||
const [routerUsername] = pathname?.replace("%20", "-").split(/[?#]/) ?? [];
|
||||
if (routerUsername && (!isValidOrgDomain || !currentOrgDomain)) {
|
||||
const splitPath = routerUsername.split("/");
|
||||
|
|
|
@ -264,6 +264,7 @@ export type UserPageProps = {
|
|||
| "slug"
|
||||
| "length"
|
||||
| "hidden"
|
||||
| "lockTimeZoneToggleOnBookingPage"
|
||||
| "requiresConfirmation"
|
||||
| "requiresBookerEmailVerification"
|
||||
| "price"
|
||||
|
@ -274,10 +275,7 @@ export type UserPageProps = {
|
|||
|
||||
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const usernameList = getUsernameList(context.query.user as string);
|
||||
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||
const dataFetchStart = Date.now();
|
||||
|
@ -342,6 +340,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
|||
slug: usernameList[0],
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
|
|
|
@ -72,10 +72,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
|||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
|
@ -148,10 +145,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const username = usernames[0];
|
||||
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
|
||||
|
@ -160,6 +154,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||
slug: usernames[0],
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
|
|
|
@ -154,7 +154,7 @@ async function getTeamLogos(subdomain: string, isValidOrgDomain: boolean) {
|
|||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { query } = req;
|
||||
const parsedQuery = logoApiSchema.parse(query);
|
||||
const { isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
const { isValidOrgDomain } = orgDomainConfig(req);
|
||||
|
||||
const hostname = req?.headers["host"];
|
||||
if (!hostname) throw new Error("No hostname");
|
||||
|
|
|
@ -29,7 +29,7 @@ const querySchema = z
|
|||
|
||||
async function getIdentityData(req: NextApiRequest) {
|
||||
const { username, teamname, orgId, orgSlug } = querySchema.parse(req.query);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req);
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ type Response = {
|
|||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<Response>): Promise<void> {
|
||||
const { currentOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
const { currentOrgDomain } = orgDomainConfig(req);
|
||||
const result = await checkUsername(req.body.username, currentOrgDomain);
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
|
|
@ -65,7 +65,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
|
||||
const session = await getServerSession({ req, res });
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req);
|
||||
|
||||
if (session) {
|
||||
// Validating if username is Premium, while this is true an email its required for stripe user confirmation
|
||||
|
|
|
@ -61,7 +61,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||
const session = await getServerSession(context);
|
||||
const { link, slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
|
|
|
@ -87,6 +87,7 @@ export type FormValues = {
|
|||
offsetStart: number;
|
||||
description: string;
|
||||
disableGuests: boolean;
|
||||
lockTimeZoneToggleOnBookingPage: boolean;
|
||||
requiresConfirmation: boolean;
|
||||
requiresBookerEmailVerification: boolean;
|
||||
recurringEvent: RecurringEvent | null;
|
||||
|
|
|
@ -72,6 +72,7 @@ import useMeQuery from "@lib/hooks/useMeQuery";
|
|||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import SkeletonLoader from "@components/eventtype/SkeletonLoader";
|
||||
import { UserAvatarGroup } from "@components/ui/avatar/UserAvatarGroup";
|
||||
|
||||
type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"];
|
||||
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
|
||||
|
@ -398,23 +399,11 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||
<div className="mt-4 hidden sm:mt-0 sm:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
{type.team && !isManagedEventType && (
|
||||
<AvatarGroup
|
||||
<UserAvatarGroup
|
||||
className="relative right-3 top-1"
|
||||
size="sm"
|
||||
truncateAfter={4}
|
||||
items={
|
||||
type?.users
|
||||
? type.users.map(
|
||||
(organizer: { name: string | null; username: string | null }) => ({
|
||||
alt: organizer.name || "",
|
||||
image: `${orgBranding?.fullDomain ?? WEBAPP_URL}/${
|
||||
organizer.username
|
||||
}/avatar.png`,
|
||||
title: organizer.name || "",
|
||||
})
|
||||
)
|
||||
: []
|
||||
}
|
||||
users={type?.users ?? []}
|
||||
/>
|
||||
)}
|
||||
{isManagedEventType && type?.children && type.children?.length > 0 && (
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "../[user]";
|
||||
|
||||
export { default } from "../[user]";
|
||||
|
||||
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
|
@ -78,8 +78,8 @@ type FormValues = {
|
|||
bio: string;
|
||||
};
|
||||
|
||||
const checkIfItFallbackImage = (fetchedImgSrc: string) => {
|
||||
return fetchedImgSrc.endsWith(AVATAR_FALLBACK);
|
||||
const checkIfItFallbackImage = (fetchedImgSrc?: string) => {
|
||||
return !fetchedImgSrc || fetchedImgSrc.endsWith(AVATAR_FALLBACK);
|
||||
};
|
||||
|
||||
const ProfileView = () => {
|
||||
|
@ -226,10 +226,11 @@ const ProfileView = () => {
|
|||
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
|
||||
};
|
||||
|
||||
if (isLoading || !user || fetchedImgSrc === undefined)
|
||||
if (isLoading || !user) {
|
||||
return (
|
||||
<SkeletonLoader title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
|
||||
);
|
||||
}
|
||||
|
||||
const defaultValues = {
|
||||
username: user.username || "",
|
||||
|
@ -282,8 +283,8 @@ const ProfileView = () => {
|
|||
/>
|
||||
|
||||
<div className="border-subtle mt-6 rounded-lg rounded-b-none border border-b-0 p-6">
|
||||
<Label className="text-base font-semibold text-red-700">{t("danger_zone")}</Label>
|
||||
<p className="text-subtle">{t("account_deletion_cannot_be_undone")}</p>
|
||||
<Label className="mb-0 text-base font-semibold text-red-700">{t("danger_zone")}</Label>
|
||||
<p className="text-subtle text-sm">{t("account_deletion_cannot_be_undone")}</p>
|
||||
</div>
|
||||
{/* Delete account Dialog */}
|
||||
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
|
||||
|
|
|
@ -269,10 +269,7 @@ function TeamPage({
|
|||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
|
||||
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||
|
||||
// Provided by Rewrite from next.config.js
|
||||
|
@ -299,6 +296,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
slug: slug,
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
|
|
|
@ -74,10 +74,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
|
||||
if (!isOrgContext) {
|
||||
|
@ -85,6 +82,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
slug: teamSlug,
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
|
|
|
@ -435,6 +435,8 @@ test.describe("Reschedule for booking with seats", () => {
|
|||
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
select: {
|
||||
|
|
|
@ -0,0 +1,483 @@
|
|||
import { loginUser } from "../../fixtures/regularBookings";
|
||||
import { test } from "../../lib/fixtures";
|
||||
|
||||
test.describe("Booking With Address Question and Each Other Question", () => {
|
||||
const bookingOptions = { hasPlaceholder: true, isRequired: true };
|
||||
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
await loginUser(users);
|
||||
await page.goto("/event-types");
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Checkbox Group Question", () => {
|
||||
test("Address required and checkbox group required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Checkbox Group question (both required)",
|
||||
secondQuestion: "checkbox",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and checkbox group not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Checkbox Group question (only address required)",
|
||||
secondQuestion: "checkbox",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Checkbox Question", () => {
|
||||
test("Address required and checkbox required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Checkbox question (both required)",
|
||||
secondQuestion: "boolean",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Addres and checkbox not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Checkbox question (only address required)",
|
||||
secondQuestion: "boolean",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Long text Question", () => {
|
||||
test("Addres required and Long Text required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Long Text question (both required)",
|
||||
secondQuestion: "textarea",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Long Text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", false, "textarea test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Long Text question (only address required)",
|
||||
secondQuestion: "textarea",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Multi email Question", () => {
|
||||
test("Address required and Multi email required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion(
|
||||
"multiemail",
|
||||
"multiemail-test",
|
||||
"multiemail test",
|
||||
true,
|
||||
"multiemail test"
|
||||
);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multiemail question (both required)",
|
||||
secondQuestion: "multiemail",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Multi email not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion(
|
||||
"multiemail",
|
||||
"multiemail-test",
|
||||
"multiemail test",
|
||||
false,
|
||||
"multiemail test"
|
||||
);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multiemail question (only address required)",
|
||||
secondQuestion: "multiemail",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and multiselect Question", () => {
|
||||
test("Address required and multiselect text required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (both required)",
|
||||
secondQuestion: "multiselect",
|
||||
options: { ...bookingOptions, isMultiSelect: true },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and multiselect text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (only address required)",
|
||||
secondQuestion: "multiselect",
|
||||
options: { ...bookingOptions, isMultiSelect: true, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Number Question", () => {
|
||||
test("Address required and Number required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("number", "number-test", "number test", true, "number test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Number question (both required)",
|
||||
secondQuestion: "number",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Number not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("number", "number-test", "number test", false, "number test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Number question (only address required)",
|
||||
secondQuestion: "number",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Phone Question", () => {
|
||||
test("Address required and Phone required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone-test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (both required)",
|
||||
secondQuestion: "phone",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Phone not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", false, "phone-test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (only address required)",
|
||||
secondQuestion: "phone",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Radio group Question", () => {
|
||||
test("Address required and Radio group required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("radio", "radio-test", "radio test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Radio question (both required)",
|
||||
secondQuestion: "radio",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Radio group not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("radio", "radio-test", "radio test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Radio question (only address required)",
|
||||
secondQuestion: "radio",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and select Question", () => {
|
||||
test("Address required and select required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Select question (both required)",
|
||||
secondQuestion: "select",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and select not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", false, "select test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Select question (both required)",
|
||||
secondQuestion: "select",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Short text question", () => {
|
||||
test("Address required and Short text required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("text", "text-test", "text test", true, "text test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (both required)",
|
||||
secondQuestion: "text",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Short text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("text", "text-test", "text test", false, "text test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (only address required)",
|
||||
secondQuestion: "text",
|
||||
options: { ...bookingOptions, isRequired: true },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,56 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import type { Team } from "@prisma/client";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
const getRandomSlug = () => `org-${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
// creates a user fixture instance and stores the collection
|
||||
export const createOrgsFixture = (page: Page) => {
|
||||
const store = { orgs: [], page } as { orgs: Team[]; page: typeof page };
|
||||
return {
|
||||
create: async (opts: { name: string; slug?: string; requestedSlug?: string }) => {
|
||||
const org = await createOrgInDb({
|
||||
name: opts.name,
|
||||
slug: opts.slug || getRandomSlug(),
|
||||
requestedSlug: opts.requestedSlug,
|
||||
});
|
||||
store.orgs.push(org);
|
||||
return org;
|
||||
},
|
||||
get: () => store.orgs,
|
||||
deleteAll: async () => {
|
||||
await prisma.team.deleteMany({ where: { id: { in: store.orgs.map((org) => org.id) } } });
|
||||
store.orgs = [];
|
||||
},
|
||||
delete: async (id: number) => {
|
||||
await prisma.team.delete({ where: { id } });
|
||||
store.orgs = store.orgs.filter((b) => b.id !== id);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
async function createOrgInDb({
|
||||
name,
|
||||
slug,
|
||||
requestedSlug,
|
||||
}: {
|
||||
name: string;
|
||||
slug: string | null;
|
||||
requestedSlug?: string;
|
||||
}) {
|
||||
return await prisma.team.create({
|
||||
data: {
|
||||
name: name,
|
||||
slug: slug,
|
||||
metadata: {
|
||||
isOrganization: true,
|
||||
...(requestedSlug
|
||||
? {
|
||||
requestedSlug,
|
||||
}
|
||||
: null),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -13,6 +13,7 @@ type BookingOptions = {
|
|||
hasPlaceholder?: boolean;
|
||||
isReschedule?: boolean;
|
||||
isRequired?: boolean;
|
||||
isMultiSelect?: boolean;
|
||||
};
|
||||
|
||||
interface QuestionActions {
|
||||
|
|
|
@ -9,6 +9,7 @@ import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/avail
|
|||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
|
||||
import { TimeZoneEnum } from "./types";
|
||||
|
@ -78,11 +79,13 @@ const createTeamAndAddUser = async (
|
|||
isUnpublished,
|
||||
isOrg,
|
||||
hasSubteam,
|
||||
organizationId,
|
||||
}: {
|
||||
user: { id: number; username: string | null; role?: MembershipRole };
|
||||
isUnpublished?: boolean;
|
||||
isOrg?: boolean;
|
||||
hasSubteam?: true;
|
||||
organizationId?: number | null;
|
||||
},
|
||||
workerInfo: WorkerInfo
|
||||
) => {
|
||||
|
@ -101,6 +104,7 @@ const createTeamAndAddUser = async (
|
|||
data.children = { connect: [{ id: team.id }] };
|
||||
}
|
||||
data.orgUsers = isOrg ? { connect: [{ id: user.id }] } : undefined;
|
||||
data.parent = organizationId ? { connect: { id: organizationId } } : undefined;
|
||||
const team = await prisma.team.create({
|
||||
data,
|
||||
});
|
||||
|
@ -114,6 +118,7 @@ const createTeamAndAddUser = async (
|
|||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
return team;
|
||||
};
|
||||
|
||||
|
@ -282,6 +287,7 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
|||
isUnpublished: scenario.isUnpublished,
|
||||
isOrg: scenario.isOrg,
|
||||
hasSubteam: scenario.hasSubteam,
|
||||
organizationId: opts?.organizationId,
|
||||
},
|
||||
workerInfo
|
||||
);
|
||||
|
@ -399,11 +405,27 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
|||
logout: async () => {
|
||||
await page.goto("/auth/logout");
|
||||
},
|
||||
getTeam: async () => {
|
||||
return prisma.membership.findFirstOrThrow({
|
||||
getFirstTeam: async () => {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { team: true },
|
||||
});
|
||||
|
||||
const membership = memberships
|
||||
.map((membership) => {
|
||||
return {
|
||||
...membership,
|
||||
team: {
|
||||
...membership.team,
|
||||
metadata: teamMetadataSchema.parse(membership.team.metadata),
|
||||
},
|
||||
};
|
||||
})
|
||||
.find((membership) => !membership.team?.metadata?.isOrganization);
|
||||
if (!membership) {
|
||||
throw new Error("No team found for user");
|
||||
}
|
||||
return membership;
|
||||
},
|
||||
getOrg: async () => {
|
||||
return prisma.membership.findFirstOrThrow({
|
||||
|
@ -453,16 +475,27 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
|||
type SupportedTestEventTypes = PrismaType.EventTypeCreateInput & {
|
||||
_bookings?: PrismaType.BookingCreateInput[];
|
||||
};
|
||||
type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "locale" | "name" | "email";
|
||||
type CustomUserOptsKeys =
|
||||
| "username"
|
||||
| "password"
|
||||
| "completedOnboarding"
|
||||
| "locale"
|
||||
| "name"
|
||||
| "email"
|
||||
| "organizationId";
|
||||
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
|
||||
timeZone?: TimeZoneEnum;
|
||||
eventTypes?: SupportedTestEventTypes[];
|
||||
// ignores adding the worker-index after username
|
||||
useExactUsername?: boolean;
|
||||
roleInOrganization?: MembershipRole;
|
||||
};
|
||||
|
||||
// creates the actual user in the db.
|
||||
const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): PrismaType.UserCreateInput => {
|
||||
const createUser = (
|
||||
workerInfo: WorkerInfo,
|
||||
opts?: CustomUserOpts | null
|
||||
): PrismaType.UserUncheckedCreateInput => {
|
||||
// build a unique name for our user
|
||||
const uname =
|
||||
opts?.useExactUsername && opts?.username
|
||||
|
@ -478,6 +511,7 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
|
|||
completedOnboarding: opts?.completedOnboarding ?? true,
|
||||
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
|
||||
locale: opts?.locale ?? "en",
|
||||
...getOrganizationRelatedProps({ organizationId: opts?.organizationId, role: opts?.roleInOrganization }),
|
||||
schedules:
|
||||
opts?.completedOnboarding ?? true
|
||||
? {
|
||||
|
@ -493,6 +527,42 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
|
|||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
function getOrganizationRelatedProps({
|
||||
organizationId,
|
||||
role,
|
||||
}: {
|
||||
organizationId: number | null | undefined;
|
||||
role: MembershipRole | undefined;
|
||||
}) {
|
||||
if (!organizationId) {
|
||||
return null;
|
||||
}
|
||||
if (!role) {
|
||||
throw new Error("Missing role for user in organization");
|
||||
}
|
||||
return {
|
||||
organizationId: organizationId || null,
|
||||
...(organizationId
|
||||
? {
|
||||
teams: {
|
||||
// Create membership
|
||||
create: [
|
||||
{
|
||||
team: {
|
||||
connect: {
|
||||
id: organizationId,
|
||||
},
|
||||
},
|
||||
accepted: true,
|
||||
role: MembershipRole.ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: null),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
async function confirmPendingPayment(page: Page) {
|
||||
|
|
|
@ -9,6 +9,7 @@ import prisma from "@calcom/prisma";
|
|||
import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
||||
import { createBookingsFixture } from "../fixtures/bookings";
|
||||
import { createEmbedsFixture } from "../fixtures/embeds";
|
||||
import { createOrgsFixture } from "../fixtures/orgs";
|
||||
import { createPaymentsFixture } from "../fixtures/payments";
|
||||
import { createBookingPageFixture } from "../fixtures/regularBookings";
|
||||
import { createRoutingFormsFixture } from "../fixtures/routingForms";
|
||||
|
@ -17,6 +18,7 @@ import { createUsersFixture } from "../fixtures/users";
|
|||
|
||||
export interface Fixtures {
|
||||
page: Page;
|
||||
orgs: ReturnType<typeof createOrgsFixture>;
|
||||
users: ReturnType<typeof createUsersFixture>;
|
||||
bookings: ReturnType<typeof createBookingsFixture>;
|
||||
payments: ReturnType<typeof createPaymentsFixture>;
|
||||
|
@ -48,6 +50,10 @@ declare global {
|
|||
* @see https://playwright.dev/docs/test-fixtures
|
||||
*/
|
||||
export const test = base.extend<Fixtures>({
|
||||
orgs: async ({ page }, use) => {
|
||||
const orgsFixture = createOrgsFixture(page);
|
||||
await use(orgsFixture);
|
||||
},
|
||||
users: async ({ page, context, emails }, use, workerInfo) => {
|
||||
const usersFixture = createUsersFixture(page, emails, workerInfo);
|
||||
await use(usersFixture);
|
||||
|
|
|
@ -150,14 +150,14 @@ test.describe("unauthorized user sees correct translations (pt)", async () => {
|
|||
|
||||
test.describe("unauthorized user sees correct translations (pt-br)", async () => {
|
||||
test.use({
|
||||
locale: "pt-br",
|
||||
locale: "pt-BR",
|
||||
});
|
||||
|
||||
test("should use correct translations and html attributes", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("load");
|
||||
|
||||
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
|
||||
await page.locator("html[lang=pt-BR]").waitFor({ state: "attached" });
|
||||
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||
|
||||
{
|
||||
|
@ -181,7 +181,8 @@ test.describe("unauthorized user sees correct translations (es-419)", async () =
|
|||
await page.goto("/");
|
||||
await page.waitForLoadState("load");
|
||||
|
||||
await page.locator("html[lang=es-419]").waitFor({ state: "attached" });
|
||||
// es-419 is disabled in i18n config, so es should be used as fallback
|
||||
await page.locator("html[lang=es]").waitFor({ state: "attached" });
|
||||
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||
|
||||
{
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth, testName, todo } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Teams", () => {
|
||||
test.describe("Teams - NonOrg", () => {
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
test("Can create teams via Wizard", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
const inviteeEmail = `${user.username}+invitee@example.com`;
|
||||
|
@ -64,6 +64,7 @@ test.describe("Teams", () => {
|
|||
// await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("Can create a booking for Collective EventType", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
|
@ -78,7 +79,7 @@ test.describe("Teams", () => {
|
|||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
});
|
||||
const { team } = await owner.getTeam();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
|
@ -99,6 +100,7 @@ test.describe("Teams", () => {
|
|||
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
|
||||
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
|
@ -113,7 +115,7 @@ test.describe("Teams", () => {
|
|||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
});
|
||||
|
||||
const { team } = await owner.getTeam();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
|
@ -135,6 +137,7 @@ test.describe("Teams", () => {
|
|||
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
|
||||
test("Non admin team members cannot create team in org", async ({ page, users }) => {
|
||||
const teamMateName = "teammate-1";
|
||||
|
||||
|
@ -169,6 +172,7 @@ test.describe("Teams", () => {
|
|||
await prisma.team.delete({ where: { id: org.teamId } });
|
||||
}
|
||||
});
|
||||
|
||||
test("Can create team with same name as user", async ({ page, users }) => {
|
||||
// Name to be used for both user and team
|
||||
const uniqueName = "test-unique-name";
|
||||
|
@ -210,6 +214,7 @@ test.describe("Teams", () => {
|
|||
await prisma.team.delete({ where: { id: team?.id } });
|
||||
});
|
||||
});
|
||||
|
||||
test("Can create a private team", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
|
@ -226,7 +231,7 @@ test.describe("Teams", () => {
|
|||
});
|
||||
|
||||
await owner.apiLogin();
|
||||
const { team } = await owner.getTeam();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
|
||||
// Mark team as private
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
|
@ -247,3 +252,180 @@ test.describe("Teams", () => {
|
|||
todo("Reschedule a Collective EventType booking");
|
||||
todo("Reschedule a Round Robin EventType booking");
|
||||
});
|
||||
|
||||
test.describe("Teams - Org", () => {
|
||||
test.afterEach(({ orgs, users }) => {
|
||||
orgs.deleteAll();
|
||||
users.deleteAll();
|
||||
});
|
||||
|
||||
test("Can create teams via Wizard", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
const user = await users.create({
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.ADMIN,
|
||||
});
|
||||
const inviteeEmail = `${user.username}+invitee@example.com`;
|
||||
await user.apiLogin();
|
||||
await page.goto("/teams");
|
||||
|
||||
await test.step("Can create team", async () => {
|
||||
// Click text=Create Team
|
||||
await page.locator("text=Create a new Team").click();
|
||||
await page.waitForURL((url) => url.pathname === "/settings/teams/new");
|
||||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
|
||||
// Click text=Continue
|
||||
await page.locator("text=Continue").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
|
||||
});
|
||||
|
||||
await test.step("Can add members", async () => {
|
||||
// Click [data-testid="new-member-button"]
|
||||
await page.locator('[data-testid="new-member-button"]').click();
|
||||
// Fill [placeholder="email\@example\.com"]
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
|
||||
// Click [data-testid="invite-new-member-button"]
|
||||
await page.locator('[data-testid="invite-new-member-button"]').click();
|
||||
await expect(page.locator(`li:has-text("${inviteeEmail}")`)).toBeVisible();
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
|
||||
});
|
||||
|
||||
await test.step("Can remove members", async () => {
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
|
||||
|
||||
const lastRemoveMemberButton = page.locator('[data-testid="remove-member-button"]').last();
|
||||
await lastRemoveMemberButton.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
|
||||
|
||||
// Cleanup here since this user is created without our fixtures.
|
||||
await prisma.user.delete({ where: { email: inviteeEmail } });
|
||||
});
|
||||
|
||||
await test.step("Can finish team creation", async () => {
|
||||
await page.locator("text=Finish").click();
|
||||
await page.waitForURL("/settings/teams");
|
||||
});
|
||||
|
||||
await test.step("Can disband team", async () => {
|
||||
await page.locator('[data-testid="team-list-item-link"]').click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
await page.locator("text=Disband Team").click();
|
||||
await page.locator("text=Yes, disband team").click();
|
||||
await page.waitForURL("/teams");
|
||||
expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("Can create a booking for Collective EventType", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
|
||||
const owner = await users.create(
|
||||
{
|
||||
username: "pro-user",
|
||||
name: "pro-user",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
}
|
||||
);
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
|
||||
await expect(page.locator('[data-testid="404-page"]')).toBeVisible();
|
||||
await doOnOrgDomain(
|
||||
{
|
||||
orgSlug: org.slug,
|
||||
page,
|
||||
},
|
||||
async () => {
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
// The title of the booking
|
||||
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
|
||||
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
|
||||
// The booker should be in the attendee list
|
||||
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
|
||||
|
||||
// All the teammates should be in the booking
|
||||
for (const teammate of teamMatesObj) {
|
||||
await expect(page.getByText(teammate.name, { exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
|
||||
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
const owner = await users.create(ownerObj, {
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
});
|
||||
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
// The person who booked the meeting should be in the attendee list
|
||||
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
|
||||
|
||||
// The title of the booking
|
||||
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
|
||||
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
|
||||
|
||||
// Since all the users have the same leastRecentlyBooked value
|
||||
// Anyone of the teammates could be the Host of the booking.
|
||||
const chosenUser = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(chosenUser).not.toBeNull();
|
||||
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
});
|
||||
|
||||
async function doOnOrgDomain(
|
||||
{ orgSlug, page }: { orgSlug: string | null; page: Page },
|
||||
callback: ({ page }: { page: Page }) => Promise<void>
|
||||
) {
|
||||
if (!orgSlug) {
|
||||
throw new Error("orgSlug is not available");
|
||||
}
|
||||
page.setExtraHTTPHeaders({
|
||||
"x-cal-force-slug": orgSlug,
|
||||
});
|
||||
await callback({ page });
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ test.afterAll(async ({ users }) => {
|
|||
test.describe("Unpublished", () => {
|
||||
test("Regular team profile", async ({ page, users }) => {
|
||||
const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true });
|
||||
const { team } = await owner.getTeam();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { requestedSlug } = team.metadata as { requestedSlug: string };
|
||||
await page.goto(`/team/${requestedSlug}`);
|
||||
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
|
||||
|
@ -33,7 +33,7 @@ test.describe("Unpublished", () => {
|
|||
isUnpublished: true,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
});
|
||||
const { team } = await owner.getTeam();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { requestedSlug } = team.metadata as { requestedSlug: string };
|
||||
const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
await page.goto(`/team/${requestedSlug}/${teamEventSlug}`);
|
||||
|
|
|
@ -605,7 +605,7 @@
|
|||
"hide_book_a_team_member": "Hide Book a Team Member Button",
|
||||
"hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.",
|
||||
"danger_zone": "Danger zone",
|
||||
"account_deletion_cannot_be_undone":"Careful. Account deletion cannot be undone.",
|
||||
"account_deletion_cannot_be_undone":"Be Careful. Account deletion cannot be undone.",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"cancel_all_remaining": "Cancel all remaining",
|
||||
|
@ -2094,5 +2094,7 @@
|
|||
"overlay_my_calendar":"Overlay my calendar",
|
||||
"overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
|
||||
"view_overlay_calendar_events":"View your calendar events to prevent clashed booking.",
|
||||
"lock_timezone_toggle_on_booking_page": "Lock timezone on booking page",
|
||||
"description_lock_timezone_toggle_on_booking_page" : "To lock the timezone on booking page, useful for in-person events.",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
|
|
@ -37,8 +37,14 @@ export async function ssgInit<TParams extends { locale?: string }>(opts: GetStat
|
|||
},
|
||||
});
|
||||
|
||||
// always preload i18n
|
||||
await ssg.viewer.public.i18n.fetch({ locale, CalComVersion: CALCOM_VERSION });
|
||||
// i18n translations are already retrieved from serverSideTranslations call, there is no need to run a i18n.fetch
|
||||
// we can set query data directly to the queryClient
|
||||
const queryKey = [
|
||||
["viewer", "public", "i18n"],
|
||||
{ input: { locale, CalComVersion: CALCOM_VERSION }, type: "query" },
|
||||
];
|
||||
|
||||
ssg.queryClient.setQueryData(queryKey, { i18n: _i18n });
|
||||
|
||||
return ssg;
|
||||
}
|
||||
|
|
|
@ -25,11 +25,17 @@ export async function ssrInit(context: GetServerSidePropsContext, options?: { no
|
|||
ctx: { ...ctx, locale, i18n },
|
||||
});
|
||||
|
||||
// i18n translations are already retrieved from serverSideTranslations call, there is no need to run a i18n.fetch
|
||||
// we can set query data directly to the queryClient
|
||||
const queryKey = [
|
||||
["viewer", "public", "i18n"],
|
||||
{ input: { locale, CalComVersion: CALCOM_VERSION }, type: "query" },
|
||||
];
|
||||
if (!options?.noI18nPreload) {
|
||||
ssr.queryClient.setQueryData(queryKey, { i18n });
|
||||
}
|
||||
|
||||
await Promise.allSettled([
|
||||
// always preload "viewer.public.i18n"
|
||||
!options?.noI18nPreload
|
||||
? ssr.viewer.public.i18n.prefetch({ locale, CalComVersion: CALCOM_VERSION })
|
||||
: Promise.resolve({}),
|
||||
// So feature flags are available on first render
|
||||
ssr.viewer.features.map.prefetch(),
|
||||
// Provides a better UX to the users who have already upgraded.
|
||||
|
|
|
@ -98,12 +98,19 @@ describe("handleChildrenEventTypes", () => {
|
|||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line
|
||||
const { schedulingType, id, teamId, timeZone, users, requiresBookerEmailVerification, ...evType } =
|
||||
mockFindFirstEventType({
|
||||
id: 123,
|
||||
metadata: { managedEventConfig: {} },
|
||||
locations: [],
|
||||
});
|
||||
const {
|
||||
schedulingType,
|
||||
id,
|
||||
teamId,
|
||||
timeZone,
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
id: 123,
|
||||
metadata: { managedEventConfig: {} },
|
||||
locations: [],
|
||||
});
|
||||
const result = await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
oldEventType: { children: [], team: { name: "" } },
|
||||
|
@ -145,6 +152,7 @@ describe("handleChildrenEventTypes", () => {
|
|||
userId,
|
||||
scheduleId,
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
|
@ -230,12 +238,19 @@ describe("handleChildrenEventTypes", () => {
|
|||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line
|
||||
const { schedulingType, id, teamId, timeZone, users, requiresBookerEmailVerification, ...evType } =
|
||||
mockFindFirstEventType({
|
||||
id: 123,
|
||||
metadata: { managedEventConfig: {} },
|
||||
locations: [],
|
||||
});
|
||||
const {
|
||||
schedulingType,
|
||||
id,
|
||||
teamId,
|
||||
timeZone,
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
id: 123,
|
||||
metadata: { managedEventConfig: {} },
|
||||
locations: [],
|
||||
});
|
||||
prismaMock.eventType.deleteMany.mockResolvedValue([123] as unknown as Prisma.BatchPayload);
|
||||
const result = await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
|
@ -277,6 +292,7 @@ describe("handleChildrenEventTypes", () => {
|
|||
parentId,
|
||||
userId,
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
|
@ -327,6 +343,7 @@ describe("handleChildrenEventTypes", () => {
|
|||
userId: _userId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
requiresBookerEmailVerification,
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
|
|
|
@ -107,7 +107,9 @@
|
|||
"@apidevtools/json-schema-ref-parser": "9.0.9",
|
||||
"@types/node": "16.9.1",
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "^18.0.9"
|
||||
"@types/react-dom": "^18.0.9",
|
||||
"libphonenumber-js@^1.10.12": "patch:libphonenumber-js@npm%3A1.10.12#./.yarn/patches/libphonenumber-js-npm-1.10.12-51c84f8bf1.patch",
|
||||
"next-i18next@^13.2.2": "patch:next-i18next@npm%3A13.3.0#./.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch"
|
||||
},
|
||||
"lint-staged": {
|
||||
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [
|
||||
|
|
|
@ -4,6 +4,7 @@ import type { calendar_v3 } from "googleapis";
|
|||
import { google } from "googleapis";
|
||||
|
||||
import { MeetLocationType } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import type CalendarService from "@calcom/lib/CalendarService";
|
||||
|
@ -369,57 +370,75 @@ export default class GoogleCalendarService implements Calendar {
|
|||
timeMin: string;
|
||||
timeMax: string;
|
||||
items: { id: string }[];
|
||||
}): Promise<calendar_v3.Schema$FreeBusyResponse> {
|
||||
}): Promise<EventBusyDate[] | null> {
|
||||
const calendar = await this.authedCalendar();
|
||||
const flags = await getFeatureFlagMap(prisma);
|
||||
|
||||
let freeBusyResult: calendar_v3.Schema$FreeBusyResponse = {};
|
||||
if (!flags["calendar-cache"]) {
|
||||
this.log.warn("Calendar Cache is disabled - Skipping");
|
||||
const { timeMin, timeMax, items } = args;
|
||||
const apires = await calendar.freebusy.query({
|
||||
requestBody: { timeMin, timeMax, items },
|
||||
});
|
||||
return apires.data;
|
||||
|
||||
freeBusyResult = apires.data;
|
||||
} else {
|
||||
const { timeMin: _timeMin, timeMax: _timeMax, items } = args;
|
||||
const { timeMin, timeMax } = handleMinMax(_timeMin, _timeMax);
|
||||
const key = JSON.stringify({ timeMin, timeMax, items });
|
||||
const cached = await prisma.calendarCache.findUnique({
|
||||
where: {
|
||||
credentialId_key: {
|
||||
credentialId: this.credential.id,
|
||||
key,
|
||||
},
|
||||
expiresAt: { gte: new Date(Date.now()) },
|
||||
},
|
||||
});
|
||||
|
||||
if (cached) {
|
||||
freeBusyResult = cached.value as unknown as calendar_v3.Schema$FreeBusyResponse;
|
||||
} else {
|
||||
const apires = await calendar.freebusy.query({
|
||||
requestBody: { timeMin, timeMax, items },
|
||||
});
|
||||
|
||||
// Skipping await to respond faster
|
||||
await prisma.calendarCache.upsert({
|
||||
where: {
|
||||
credentialId_key: {
|
||||
credentialId: this.credential.id,
|
||||
key,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value: JSON.parse(JSON.stringify(apires.data)),
|
||||
expiresAt: new Date(Date.now() + CACHING_TIME),
|
||||
},
|
||||
create: {
|
||||
value: JSON.parse(JSON.stringify(apires.data)),
|
||||
credentialId: this.credential.id,
|
||||
key,
|
||||
expiresAt: new Date(Date.now() + CACHING_TIME),
|
||||
},
|
||||
});
|
||||
|
||||
freeBusyResult = apires.data;
|
||||
}
|
||||
}
|
||||
const { timeMin: _timeMin, timeMax: _timeMax, items } = args;
|
||||
const { timeMin, timeMax } = handleMinMax(_timeMin, _timeMax);
|
||||
const key = JSON.stringify({ timeMin, timeMax, items });
|
||||
const cached = await prisma.calendarCache.findUnique({
|
||||
where: {
|
||||
credentialId_key: {
|
||||
credentialId: this.credential.id,
|
||||
key,
|
||||
},
|
||||
expiresAt: { gte: new Date(Date.now()) },
|
||||
},
|
||||
});
|
||||
if (!freeBusyResult.calendars) return null;
|
||||
|
||||
if (cached) return cached.value as unknown as calendar_v3.Schema$FreeBusyResponse;
|
||||
|
||||
const apires = await calendar.freebusy.query({
|
||||
requestBody: { timeMin, timeMax, items },
|
||||
});
|
||||
|
||||
// Skipping await to respond faster
|
||||
await prisma.calendarCache.upsert({
|
||||
where: {
|
||||
credentialId_key: {
|
||||
credentialId: this.credential.id,
|
||||
key,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value: JSON.parse(JSON.stringify(apires.data)),
|
||||
expiresAt: new Date(Date.now() + CACHING_TIME),
|
||||
},
|
||||
create: {
|
||||
value: JSON.parse(JSON.stringify(apires.data)),
|
||||
credentialId: this.credential.id,
|
||||
key,
|
||||
expiresAt: new Date(Date.now() + CACHING_TIME),
|
||||
},
|
||||
});
|
||||
|
||||
return apires.data;
|
||||
const result = Object.values(freeBusyResult.calendars).reduce((c, i) => {
|
||||
i.busy?.forEach((busyTime) => {
|
||||
c.push({
|
||||
start: busyTime.start || "",
|
||||
end: busyTime.end || "",
|
||||
});
|
||||
});
|
||||
return c;
|
||||
}, [] as Prisma.PromiseReturnType<CalendarService["getAvailability"]>);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getAvailability(
|
||||
|
@ -444,22 +463,44 @@ export default class GoogleCalendarService implements Calendar {
|
|||
|
||||
try {
|
||||
const calsIds = await getCalIds();
|
||||
const freeBusyData = await this.getCacheOrFetchAvailability({
|
||||
timeMin: dateFrom,
|
||||
timeMax: dateTo,
|
||||
items: calsIds.map((id) => ({ id })),
|
||||
});
|
||||
if (!freeBusyData?.calendars) throw new Error("No response from google calendar");
|
||||
const result = Object.values(freeBusyData.calendars).reduce((c, i) => {
|
||||
i.busy?.forEach((busyTime) => {
|
||||
c.push({
|
||||
start: busyTime.start || "",
|
||||
end: busyTime.end || "",
|
||||
});
|
||||
const originalStartDate = dayjs(dateFrom);
|
||||
const originalEndDate = dayjs(dateTo);
|
||||
const diff = originalEndDate.diff(originalStartDate, "days");
|
||||
|
||||
// /freebusy from google api only allows a date range of 90 days
|
||||
if (diff <= 90) {
|
||||
const freeBusyData = await this.getCacheOrFetchAvailability({
|
||||
timeMin: dateFrom,
|
||||
timeMax: dateTo,
|
||||
items: calsIds.map((id) => ({ id })),
|
||||
});
|
||||
return c;
|
||||
}, [] as Prisma.PromiseReturnType<CalendarService["getAvailability"]>);
|
||||
return result;
|
||||
if (!freeBusyData) throw new Error("No response from google calendar");
|
||||
|
||||
return freeBusyData;
|
||||
} else {
|
||||
const busyData = [];
|
||||
|
||||
const loopsNumber = Math.ceil(diff / 90);
|
||||
|
||||
let startDate = originalStartDate;
|
||||
let endDate = originalStartDate.add(90, "days");
|
||||
|
||||
for (let i = 0; i < loopsNumber; i++) {
|
||||
if (endDate.isAfter(originalEndDate)) endDate = originalEndDate;
|
||||
|
||||
busyData.push(
|
||||
...((await this.getCacheOrFetchAvailability({
|
||||
timeMin: startDate.format(),
|
||||
timeMax: endDate.format(),
|
||||
items: calsIds.map((id) => ({ id })),
|
||||
})) || [])
|
||||
);
|
||||
|
||||
startDate = endDate.add(1, "minutes");
|
||||
endDate = startDate.add(90, "days");
|
||||
}
|
||||
return busyData;
|
||||
}
|
||||
} catch (error) {
|
||||
this.log.error("There was an error contacting google calendar service: ", error);
|
||||
throw error;
|
||||
|
|
|
@ -54,7 +54,7 @@ export const getServerSideProps = async function getServerSideProps(
|
|||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { form: formId, slug: _slug, pages: _pages, ...fieldsResponses } = queryParsed.data;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
|
||||
|
||||
const form = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
|
|
|
@ -248,7 +248,7 @@ export const getServerSideProps = async function getServerSideProps(
|
|||
notFound: true,
|
||||
};
|
||||
}
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
|
||||
|
||||
const isEmbed = params.appPages[1] === "embed";
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapt
|
|||
import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse";
|
||||
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
|
||||
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
|
||||
import metadata from "../_metadata";
|
||||
import { getZoomAppKeys } from "./getZoomAppKeys";
|
||||
|
||||
/** @link https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate */
|
||||
|
@ -91,7 +92,7 @@ const zoomAuth = (credential: CredentialPayload) => {
|
|||
grant_type: "refresh_token",
|
||||
}),
|
||||
}),
|
||||
"zoom",
|
||||
metadata.slug,
|
||||
credential.userId
|
||||
);
|
||||
|
||||
|
|
|
@ -489,6 +489,22 @@ export default class EventManager {
|
|||
*/
|
||||
private async createAllCalendarEvents(event: CalendarEvent) {
|
||||
let createdEvents: EventResult<NewCalendarEventType>[] = [];
|
||||
|
||||
const fallbackToFirstConnectedCalendar = async () => {
|
||||
/**
|
||||
* Not ideal but, if we don't find a destination calendar,
|
||||
* fallback to the first connected calendar - Shouldn't be a CRM calendar
|
||||
*/
|
||||
const [credential] = this.calendarCredentials.filter((cred) => !cred.type.endsWith("other_calendar"));
|
||||
if (credential) {
|
||||
const createdEvent = await createEvent(credential, event);
|
||||
log.silly("Created Calendar event", safeStringify({ createdEvent }));
|
||||
if (createdEvent) {
|
||||
createdEvents.push(createdEvent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (event.destinationCalendar && event.destinationCalendar.length > 0) {
|
||||
// Since GCal pushes events to multiple calendars we only want to create one event per booking
|
||||
let gCalAdded = false;
|
||||
|
@ -545,6 +561,14 @@ export default class EventManager {
|
|||
);
|
||||
// It might not be the first connected calendar as it seems that the order is not guaranteed to be ascending of credentialId.
|
||||
const firstCalendarCredential = destinationCalendarCredentials[0];
|
||||
|
||||
if (!firstCalendarCredential) {
|
||||
log.warn(
|
||||
"No other credentials found of the same type as the destination calendar. Falling back to first connected calendar"
|
||||
);
|
||||
await fallbackToFirstConnectedCalendar();
|
||||
}
|
||||
|
||||
log.warn(
|
||||
"No credentialId found for destination calendar, falling back to first found calendar",
|
||||
safeStringify({
|
||||
|
@ -563,19 +587,7 @@ export default class EventManager {
|
|||
calendarCredentials: this.calendarCredentials,
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Not ideal but, if we don't find a destination calendar,
|
||||
* fallback to the first connected calendar - Shouldn't be a CRM calendar
|
||||
*/
|
||||
const [credential] = this.calendarCredentials.filter((cred) => !cred.type.endsWith("other_calendar"));
|
||||
if (credential) {
|
||||
const createdEvent = await createEvent(credential, event);
|
||||
log.silly("Created Calendar event", safeStringify({ createdEvent }));
|
||||
if (createdEvent) {
|
||||
createdEvents.push(createdEvent);
|
||||
}
|
||||
}
|
||||
await fallbackToFirstConnectedCalendar();
|
||||
}
|
||||
|
||||
// Taking care of non-traditional calendar integrations
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import { parse } from "accept-language-parser";
|
||||
import { lookup } from "bcp-47-match";
|
||||
import type { GetTokenParams } from "next-auth/jwt";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
|
||||
//@ts-expect-error no type definitions
|
||||
import { i18n } from "@calcom/web/next-i18next.config";
|
||||
|
||||
/**
|
||||
* This is a slimmed down version of the `getServerSession` function from
|
||||
* `next-auth`.
|
||||
|
@ -40,5 +44,9 @@ export const getLocale = async (req: GetTokenParams["req"]): Promise<string> =>
|
|||
// the regex underneath is more permissive
|
||||
const testedRegion = /^[a-zA-Z0-9]+$/.test(region) ? region : "";
|
||||
|
||||
return `${testedCode}${testedRegion !== "" ? "-" : ""}${testedRegion}`;
|
||||
const requestedLocale = `${testedCode}${testedRegion !== "" ? "-" : ""}${testedRegion}`;
|
||||
|
||||
// use fallback to closest supported locale.
|
||||
// for instance, es-419 will be transformed to es
|
||||
return lookup(i18n.locales, requestedLocale) ?? requestedLocale;
|
||||
};
|
||||
|
|
|
@ -105,6 +105,7 @@ export const EventMeta = () => {
|
|||
</EventMetaBlock>
|
||||
)}
|
||||
<EventDetails event={event} />
|
||||
|
||||
<EventMetaBlock
|
||||
className="cursor-pointer [&_.current-timezone:before]:focus-within:opacity-100 [&_.current-timezone:before]:hover:opacity-100"
|
||||
contentClassName="relative max-w-[90%]"
|
||||
|
@ -112,7 +113,10 @@ export const EventMeta = () => {
|
|||
{bookerState === "booking" ? (
|
||||
<>{timezone}</>
|
||||
) : (
|
||||
<span className="min-w-32 current-timezone before:bg-subtle -mt-[2px] flex h-6 max-w-full items-center justify-start before:absolute before:inset-0 before:bottom-[-3px] before:left-[-30px] before:top-[-3px] before:w-[calc(100%_+_35px)] before:rounded-md before:py-3 before:opacity-0 before:transition-opacity">
|
||||
<span
|
||||
className={`min-w-32 current-timezone before:bg-subtle -mt-[2px] flex h-6 max-w-full items-center justify-start before:absolute before:inset-0 before:bottom-[-3px] before:left-[-30px] before:top-[-3px] before:w-[calc(100%_+_35px)] before:rounded-md before:py-3 before:opacity-0 before:transition-opacity ${
|
||||
event.lockTimeZoneToggleOnBookingPage ? "cursor-not-allowed" : ""
|
||||
}`}>
|
||||
<TimezoneSelect
|
||||
menuPosition="fixed"
|
||||
classNames={{
|
||||
|
@ -124,6 +128,7 @@ export const EventMeta = () => {
|
|||
}}
|
||||
value={timezone}
|
||||
onChange={(tz) => setTimezone(tz.value)}
|
||||
isDisabled={event.lockTimeZoneToggleOnBookingPage}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
|
|
@ -276,6 +276,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
|||
periodEndDate: true,
|
||||
periodDays: true,
|
||||
periodCountCalendarDays: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
requiresConfirmation: true,
|
||||
requiresBookerEmailVerification: true,
|
||||
userId: true,
|
||||
|
@ -1851,6 +1852,7 @@ async function handler(
|
|||
...eventTypeInfo,
|
||||
uid: resultBooking?.uid || uid,
|
||||
bookingId: booking?.id,
|
||||
rescheduleId: originalRescheduledBooking?.id || undefined,
|
||||
rescheduleUid,
|
||||
rescheduleStartTime: originalRescheduledBooking?.startTime
|
||||
? dayjs(originalRescheduledBooking?.startTime).utc().format()
|
||||
|
@ -2377,6 +2379,7 @@ async function handler(
|
|||
...evt,
|
||||
...eventTypeInfo,
|
||||
bookingId: booking?.id,
|
||||
rescheduleId: originalRescheduledBooking?.id || undefined,
|
||||
rescheduleUid,
|
||||
rescheduleStartTime: originalRescheduledBooking?.startTime
|
||||
? dayjs(originalRescheduledBooking?.startTime).utc().format()
|
||||
|
@ -2684,6 +2687,7 @@ const findBookingQuery = async (bookingId: number) => {
|
|||
description: true,
|
||||
currency: true,
|
||||
length: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
requiresConfirmation: true,
|
||||
requiresBookerEmailVerification: true,
|
||||
price: true,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { getAvailableDatesInMonth } from "@calcom/features/calendars/lib/getAvailableDatesInMonth";
|
||||
import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns";
|
||||
|
@ -8,7 +8,7 @@ describe("Test Suite: Date Picker", () => {
|
|||
// *) Use right amount of days in given month. (28, 30, 31)
|
||||
test("it returns the right amount of days in a given month", () => {
|
||||
const currentDate = new Date();
|
||||
const nextMonthDate = new Date(Date.UTC(currentDate.getFullYear(), currentDate.getMonth() + 1));
|
||||
const nextMonthDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1);
|
||||
|
||||
const result = getAvailableDatesInMonth({
|
||||
browsingDate: nextMonthDate,
|
||||
|
@ -35,5 +35,33 @@ describe("Test Suite: Date Picker", () => {
|
|||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("it translates correctly regardless of system time", () => {
|
||||
{
|
||||
// test a date in negative UTC offset
|
||||
vi.useFakeTimers().setSystemTime(new Date("2023-10-24T13:27:00.000-07:00"));
|
||||
|
||||
const currentDate = new Date();
|
||||
const result = getAvailableDatesInMonth({
|
||||
browsingDate: currentDate,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(daysInMonth(currentDate) - currentDate.getDate() + 1);
|
||||
}
|
||||
{
|
||||
// test a date in positive UTC offset
|
||||
vi.useFakeTimers().setSystemTime(new Date("2023-10-24T13:27:00.000+07:00"));
|
||||
|
||||
const currentDate = new Date();
|
||||
const result = getAvailableDatesInMonth({
|
||||
browsingDate: currentDate,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(daysInMonth(currentDate) - currentDate.getDate() + 1);
|
||||
}
|
||||
// Undo the forced time we applied earlier, reset to system default.
|
||||
vi.setSystemTime(vi.getRealSystemTime());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,7 +5,7 @@ import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns";
|
|||
// *) Dates in the past are not available.
|
||||
// *) Use right amount of days in given month. (28, 30, 31)
|
||||
export function getAvailableDatesInMonth({
|
||||
browsingDate, // pass as UTC
|
||||
browsingDate,
|
||||
minDate = new Date(),
|
||||
includedDates,
|
||||
}: {
|
||||
|
@ -15,12 +15,14 @@ export function getAvailableDatesInMonth({
|
|||
}) {
|
||||
const dates = [];
|
||||
const lastDateOfMonth = new Date(
|
||||
Date.UTC(browsingDate.getFullYear(), browsingDate.getMonth(), daysInMonth(browsingDate))
|
||||
browsingDate.getFullYear(),
|
||||
browsingDate.getMonth(),
|
||||
daysInMonth(browsingDate)
|
||||
);
|
||||
for (
|
||||
let date = browsingDate > minDate ? browsingDate : minDate;
|
||||
date <= lastDateOfMonth;
|
||||
date = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate() + 1))
|
||||
date = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1)
|
||||
) {
|
||||
// intersect included dates
|
||||
if (includedDates && !includedDates.includes(yyyymmdd(date))) {
|
||||
|
|
|
@ -1,16 +1,33 @@
|
|||
import type { Prisma } from "@prisma/client";
|
||||
import type { IncomingMessage } from "http";
|
||||
|
||||
import { ALLOWED_HOSTNAMES, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
|
||||
const log = logger.getSubLogger({
|
||||
prefix: ["orgDomains.ts"],
|
||||
});
|
||||
/**
|
||||
* return the org slug
|
||||
* @param hostname
|
||||
*/
|
||||
export function getOrgSlug(hostname: string) {
|
||||
export function getOrgSlug(hostname: string, forcedSlug?: string) {
|
||||
if (forcedSlug) {
|
||||
if (process.env.NEXT_PUBLIC_IS_E2E) {
|
||||
log.debug("Using provided forcedSlug in E2E", {
|
||||
forcedSlug,
|
||||
});
|
||||
return forcedSlug;
|
||||
}
|
||||
log.debug("Ignoring forcedSlug in non-test mode", {
|
||||
forcedSlug,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hostname.includes(".")) {
|
||||
// A no-dot domain can never be org domain. It automatically handles localhost
|
||||
log.warn('Org support not enabled for hostname without "."', { hostname });
|
||||
// A no-dot domain can never be org domain. It automatically considers localhost to be non-org domain
|
||||
return null;
|
||||
}
|
||||
// Find which hostname is being currently used
|
||||
|
@ -19,24 +36,45 @@ export function getOrgSlug(hostname: string) {
|
|||
const testHostname = `${url.hostname}${url.port ? `:${url.port}` : ""}`;
|
||||
return testHostname.endsWith(`.${ahn}`);
|
||||
});
|
||||
logger.debug(`getOrgSlug: ${hostname} ${currentHostname}`, {
|
||||
ALLOWED_HOSTNAMES,
|
||||
WEBAPP_URL,
|
||||
currentHostname,
|
||||
hostname,
|
||||
});
|
||||
if (currentHostname) {
|
||||
// Define which is the current domain/subdomain
|
||||
const slug = hostname.replace(`.${currentHostname}` ?? "", "");
|
||||
return slug.indexOf(".") === -1 ? slug : null;
|
||||
|
||||
if (!currentHostname) {
|
||||
log.warn("Match of WEBAPP_URL with ALLOWED_HOSTNAME failed", { WEBAPP_URL, ALLOWED_HOSTNAMES });
|
||||
return null;
|
||||
}
|
||||
// Define which is the current domain/subdomain
|
||||
const slug = hostname.replace(`.${currentHostname}` ?? "", "");
|
||||
const hasNoDotInSlug = slug.indexOf(".") === -1;
|
||||
if (hasNoDotInSlug) {
|
||||
return slug;
|
||||
}
|
||||
log.warn("Derived slug ended up having dots, so not considering it an org domain", { slug });
|
||||
return null;
|
||||
}
|
||||
|
||||
export function orgDomainConfig(hostname: string, fallback?: string | string[]) {
|
||||
const currentOrgDomain = getOrgSlug(hostname);
|
||||
export function orgDomainConfig(req: IncomingMessage | undefined, fallback?: string | string[]) {
|
||||
const forcedSlugHeader = req?.headers?.["x-cal-force-slug"];
|
||||
|
||||
const forcedSlug = forcedSlugHeader instanceof Array ? forcedSlugHeader[0] : forcedSlugHeader;
|
||||
|
||||
const hostname = req?.headers?.host || "";
|
||||
return getOrgDomainConfigFromHostname({
|
||||
hostname,
|
||||
fallback,
|
||||
forcedSlug,
|
||||
});
|
||||
}
|
||||
|
||||
export function getOrgDomainConfigFromHostname({
|
||||
hostname,
|
||||
fallback,
|
||||
forcedSlug,
|
||||
}: {
|
||||
hostname: string;
|
||||
fallback?: string | string[];
|
||||
forcedSlug?: string;
|
||||
}) {
|
||||
const currentOrgDomain = getOrgSlug(hostname, forcedSlug);
|
||||
const isValidOrgDomain = currentOrgDomain !== null && !RESERVED_SUBDOMAINS.includes(currentOrgDomain);
|
||||
logger.debug(`orgDomainConfig: ${hostname} ${currentOrgDomain} ${isValidOrgDomain}`);
|
||||
if (isValidOrgDomain || !fallback) {
|
||||
return {
|
||||
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
|
||||
|
@ -100,6 +138,6 @@ export function whereClauseForOrgWithSlugOrRequestedSlug(slug: string) {
|
|||
}
|
||||
|
||||
export function userOrgQuery(hostname: string, fallback?: string | string[]) {
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(hostname, fallback);
|
||||
const { currentOrgDomain, isValidOrgDomain } = getOrgDomainConfigFromHostname({ hostname, fallback });
|
||||
return isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null;
|
||||
}
|
||||
|
|
|
@ -183,6 +183,7 @@ export default function TeamListItem(props: Props) {
|
|||
<div className={classNames("flex items-center justify-between", !isInvitee && "hover:bg-muted group")}>
|
||||
{!isInvitee ? (
|
||||
<Link
|
||||
data-testid="team-list-item-link"
|
||||
href={`/settings/teams/${team.id}/profile`}
|
||||
className="flex-grow cursor-pointer truncate text-sm"
|
||||
title={`${team.name}`}>
|
||||
|
|
|
@ -26,6 +26,7 @@ const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
|
|||
const senderEmail = process.env.SENDGRID_EMAIL as string;
|
||||
|
||||
sgMail.setApiKey(sendgridAPIKey);
|
||||
client.setApiKey(sendgridAPIKey);
|
||||
|
||||
type Booking = Prisma.BookingGetPayload<{
|
||||
include: {
|
||||
|
@ -106,6 +107,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
|
||||
const pageSize = 90;
|
||||
let pageNumber = 0;
|
||||
const deletePromises = [];
|
||||
|
||||
//delete batch_ids with already past scheduled date from scheduled_sends
|
||||
while (true) {
|
||||
|
@ -128,19 +130,25 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
break;
|
||||
}
|
||||
|
||||
for (const reminder of remindersToDelete) {
|
||||
try {
|
||||
await client.request({
|
||||
deletePromises.push(
|
||||
remindersToDelete.map((reminder) =>
|
||||
client.request({
|
||||
url: `/v3/user/scheduled_sends/${reminder.referenceId}`,
|
||||
method: "DELETE",
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(`Error deleting batch id from scheduled_sends: ${error}`);
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
pageNumber++;
|
||||
}
|
||||
|
||||
Promise.allSettled(deletePromises).then((results) => {
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
console.log(`Error deleting batch id from scheduled_sends: ${result.reason}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await prisma.workflowReminder.deleteMany({
|
||||
where: {
|
||||
method: WorkflowMethods.EMAIL,
|
||||
|
@ -153,6 +161,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
//cancel reminders for cancelled/rescheduled bookings that are scheduled within the next hour
|
||||
|
||||
pageNumber = 0;
|
||||
|
||||
const allPromisesCancelReminders = [];
|
||||
|
||||
while (true) {
|
||||
const remindersToCancel = await prisma.workflowReminder.findMany({
|
||||
where: {
|
||||
|
@ -175,32 +186,39 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
}
|
||||
|
||||
for (const reminder of remindersToCancel) {
|
||||
try {
|
||||
await client.request({
|
||||
url: "/v3/user/scheduled_sends",
|
||||
method: "POST",
|
||||
body: {
|
||||
batch_id: reminder.referenceId,
|
||||
status: "cancel",
|
||||
},
|
||||
});
|
||||
const cancelPromise = client.request({
|
||||
url: "/v3/user/scheduled_sends",
|
||||
method: "POST",
|
||||
body: {
|
||||
batch_id: reminder.referenceId,
|
||||
status: "cancel",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.workflowReminder.update({
|
||||
where: {
|
||||
id: reminder.id,
|
||||
},
|
||||
data: {
|
||||
scheduled: false, // to know which reminder already got cancelled (to avoid error from cancelling the same reminders again)
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(`Error cancelling scheduled Emails: ${error}`);
|
||||
}
|
||||
const updatePromise = prisma.workflowReminder.update({
|
||||
where: {
|
||||
id: reminder.id,
|
||||
},
|
||||
data: {
|
||||
scheduled: false, // to know which reminder already got cancelled (to avoid error from cancelling the same reminders again)
|
||||
},
|
||||
});
|
||||
|
||||
allPromisesCancelReminders.push(cancelPromise, updatePromise);
|
||||
}
|
||||
pageNumber++;
|
||||
}
|
||||
|
||||
Promise.allSettled(allPromisesCancelReminders).then((results) => {
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
console.log(`Error cancelling scheduled_sends: ${result.reason}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
pageNumber = 0;
|
||||
const sendEmailPromises = [];
|
||||
|
||||
while (true) {
|
||||
//find all unscheduled Email reminders
|
||||
|
@ -390,34 +408,36 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
const batchId = batchIdResponse[1].batch_id;
|
||||
|
||||
if (reminder.workflowStep.action !== WorkflowActions.EMAIL_ADDRESS) {
|
||||
await sgMail.send({
|
||||
to: sendTo,
|
||||
from: {
|
||||
email: senderEmail,
|
||||
name: reminder.workflowStep.sender || "Cal.com",
|
||||
},
|
||||
subject: emailContent.emailSubject,
|
||||
html: emailContent.emailBody,
|
||||
batchId: batchId,
|
||||
sendAt: dayjs(reminder.scheduledDate).unix(),
|
||||
replyTo: reminder.booking.user?.email || senderEmail,
|
||||
mailSettings: {
|
||||
sandboxMode: {
|
||||
enable: sandboxMode,
|
||||
sendEmailPromises.push(
|
||||
sgMail.send({
|
||||
to: sendTo,
|
||||
from: {
|
||||
email: senderEmail,
|
||||
name: reminder.workflowStep.sender || "Cal.com",
|
||||
},
|
||||
},
|
||||
attachments: reminder.workflowStep.includeCalendarEvent
|
||||
? [
|
||||
{
|
||||
content: Buffer.from(getiCalEventAsString(reminder.booking) || "").toString("base64"),
|
||||
filename: "event.ics",
|
||||
type: "text/calendar; method=REQUEST",
|
||||
disposition: "attachment",
|
||||
contentId: uuidv4(),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
subject: emailContent.emailSubject,
|
||||
html: emailContent.emailBody,
|
||||
batchId: batchId,
|
||||
sendAt: dayjs(reminder.scheduledDate).unix(),
|
||||
replyTo: reminder.booking.user?.email || senderEmail,
|
||||
mailSettings: {
|
||||
sandboxMode: {
|
||||
enable: sandboxMode,
|
||||
},
|
||||
},
|
||||
attachments: reminder.workflowStep.includeCalendarEvent
|
||||
? [
|
||||
{
|
||||
content: Buffer.from(getiCalEventAsString(reminder.booking) || "").toString("base64"),
|
||||
filename: "event.ics",
|
||||
type: "text/calendar; method=REQUEST",
|
||||
disposition: "attachment",
|
||||
contentId: uuidv4(),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.workflowReminder.update({
|
||||
|
@ -436,6 +456,15 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
}
|
||||
pageNumber++;
|
||||
}
|
||||
|
||||
Promise.allSettled(sendEmailPromises).then((results) => {
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
console.log("Email sending failed", result.reason);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
res.status(200).json({ message: "Emails scheduled" });
|
||||
}
|
||||
|
||||
|
|
|
@ -79,6 +79,7 @@ export default function CreateEventTypeDialog({
|
|||
membershipRole: MembershipRole | null | undefined;
|
||||
}[];
|
||||
}) {
|
||||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
|
@ -116,6 +117,7 @@ export default function CreateEventTypeDialog({
|
|||
|
||||
const createMutation = trpc.viewer.eventTypes.create.useMutation({
|
||||
onSuccess: async ({ eventType }) => {
|
||||
await utils.viewer.eventTypes.getByViewer.invalidate();
|
||||
await router.replace(`/event-types/${eventType.id}`);
|
||||
showToast(
|
||||
t("event_type_created_successfully", {
|
||||
|
|
|
@ -33,6 +33,7 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
|||
customInputs: true,
|
||||
disableGuests: true,
|
||||
metadata: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
requiresConfirmation: true,
|
||||
requiresBookerEmailVerification: true,
|
||||
recurringEvent: true,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { orgDomainConfig, getOrgSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getOrgSlug, getOrgDomainConfigFromHostname } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import * as constants from "@calcom/lib/constants";
|
||||
|
||||
function setupEnvs({ WEBAPP_URL = "https://app.cal.com" } = {}) {
|
||||
|
@ -35,10 +35,10 @@ function setupEnvs({ WEBAPP_URL = "https://app.cal.com" } = {}) {
|
|||
}
|
||||
|
||||
describe("Org Domains Utils", () => {
|
||||
describe("orgDomainConfig", () => {
|
||||
describe("getOrgDomainConfigFromHostname", () => {
|
||||
it("should return a valid org domain", () => {
|
||||
setupEnvs();
|
||||
expect(orgDomainConfig("acme.cal.com")).toEqual({
|
||||
expect(getOrgDomainConfigFromHostname({ hostname: "acme.cal.com" })).toEqual({
|
||||
currentOrgDomain: "acme",
|
||||
isValidOrgDomain: true,
|
||||
});
|
||||
|
@ -46,7 +46,7 @@ describe("Org Domains Utils", () => {
|
|||
|
||||
it("should return a non valid org domain", () => {
|
||||
setupEnvs();
|
||||
expect(orgDomainConfig("app.cal.com")).toEqual({
|
||||
expect(getOrgDomainConfigFromHostname({ hostname: "app.cal.com" })).toEqual({
|
||||
currentOrgDomain: null,
|
||||
isValidOrgDomain: false,
|
||||
});
|
||||
|
@ -54,7 +54,7 @@ describe("Org Domains Utils", () => {
|
|||
|
||||
it("should return a non valid org domain for localhost", () => {
|
||||
setupEnvs();
|
||||
expect(orgDomainConfig("localhost:3000")).toEqual({
|
||||
expect(getOrgDomainConfigFromHostname({ hostname: "localhost:3000" })).toEqual({
|
||||
currentOrgDomain: null,
|
||||
isValidOrgDomain: false,
|
||||
});
|
||||
|
|
|
@ -193,6 +193,7 @@ export function AvailabilitySliderTable() {
|
|||
<>
|
||||
<div className="relative">
|
||||
<DataTable
|
||||
searchKey="member"
|
||||
tableContainerRef={tableContainerRef}
|
||||
columns={memorisedColumns}
|
||||
onRowMouseclick={(row) => {
|
||||
|
|
|
@ -93,6 +93,14 @@ export const tips = [
|
|||
description: "Get a better understanding of your business",
|
||||
href: "https://go.cal.com/insights",
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
thumbnailUrl: "https://ph-files.imgix.net/46d376e1-f897-40fc-9921-c64de971ee13.jpeg?auto=compress&codec=mozjpeg&cs=strip&auto=format&w=390&h=220&fit=max&dpr=2",
|
||||
mediaLink: "https://go.cal.com/cal-ai",
|
||||
title: "Cal.ai",
|
||||
description: "Your personal AI scheduling assistant",
|
||||
href: "https://go.cal.com/cal-ai",
|
||||
}
|
||||
];
|
||||
|
||||
const reversedTips = tips.slice(0).reverse();
|
||||
|
|
|
@ -188,7 +188,7 @@ export async function listBookings(
|
|||
};
|
||||
} else {
|
||||
where.eventType = {
|
||||
teamId: account.id,
|
||||
OR: [{ teamId: account.id }, { parent: { teamId: account.id } }],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ export type WebhookDataType = CalendarEvent &
|
|||
bookingId?: number;
|
||||
status?: string;
|
||||
smsReminderNumber?: string;
|
||||
rescheduleId?: number;
|
||||
rescheduleUid?: string;
|
||||
rescheduleStartTime?: string;
|
||||
rescheduleEndTime?: string;
|
||||
|
|
|
@ -86,6 +86,7 @@ const commons = {
|
|||
recurringEvent: null,
|
||||
destinationCalendar: null,
|
||||
team: null,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresConfirmation: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
bookingLimits: null,
|
||||
|
|
|
@ -4,10 +4,9 @@ import { getLocationGroupedOptions } from "@calcom/app-store/server";
|
|||
import type { StripeData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||
import type { LocationObject } from "@calcom/core/location";
|
||||
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { parseBookingLimit, parseDurationLimit, parseRecurringEvent } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { Credential } from "@calcom/prisma/client";
|
||||
|
@ -36,6 +35,7 @@ export default async function getEventTypeById({
|
|||
username: true,
|
||||
id: true,
|
||||
email: true,
|
||||
organizationId: true,
|
||||
locale: true,
|
||||
defaultScheduleId: true,
|
||||
});
|
||||
|
@ -89,6 +89,7 @@ export default async function getEventTypeById({
|
|||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
periodCountCalendarDays: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
requiresConfirmation: true,
|
||||
requiresBookerEmailVerification: true,
|
||||
recurringEvent: true,
|
||||
|
@ -298,9 +299,7 @@ export default async function getEventTypeById({
|
|||
const eventTypeUsers: ((typeof eventType.users)[number] & { avatar: string })[] = eventType.users.map(
|
||||
(user) => ({
|
||||
...user,
|
||||
avatar: `${eventType.team?.parent?.slug ? getOrgFullOrigin(eventType.team?.parent?.slug) : CAL_URL}/${
|
||||
user.username
|
||||
}/avatar.png`,
|
||||
avatar: getUserAvatarUrl(user),
|
||||
})
|
||||
);
|
||||
|
||||
|
@ -346,11 +345,7 @@ export default async function getEventTypeById({
|
|||
.map((member) => {
|
||||
const user: typeof member.user & { avatar: string } = {
|
||||
...member.user,
|
||||
avatar: `${
|
||||
eventTypeObject.team?.parent?.slug
|
||||
? getOrgFullOrigin(eventTypeObject.team?.parent?.slug)
|
||||
: CAL_URL
|
||||
}/${member.user.username}/avatar.png`,
|
||||
avatar: getUserAvatarUrl(member.user),
|
||||
};
|
||||
return {
|
||||
...user,
|
||||
|
|
|
@ -59,18 +59,12 @@ export function getPiiFreeBooking(booking: {
|
|||
}
|
||||
|
||||
export function getPiiFreeCredential(credential: Partial<Credential>) {
|
||||
return {
|
||||
id: credential.id,
|
||||
invalid: credential.invalid,
|
||||
appId: credential.appId,
|
||||
userId: credential.userId,
|
||||
type: credential.type,
|
||||
teamId: credential.teamId,
|
||||
/**
|
||||
* Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not
|
||||
*/
|
||||
key: getBooleanStatus(credential.key),
|
||||
};
|
||||
/**
|
||||
* Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not
|
||||
*/
|
||||
const booleanKeyStatus = getBooleanStatus(credential?.key);
|
||||
|
||||
return { ...credential, key: booleanKeyStatus };
|
||||
}
|
||||
|
||||
export function getPiiFreeSelectedCalendar(selectedCalendar: Partial<SelectedCalendar>) {
|
||||
|
|
|
@ -85,6 +85,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
|
|||
periodDays: null,
|
||||
periodCountCalendarDays: null,
|
||||
recurringEvent: null,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresConfirmation: false,
|
||||
disableGuests: false,
|
||||
hideCalendarNotes: false,
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "EventType" ADD COLUMN "lockTimeZoneToggleOnBookingPage" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -86,6 +86,7 @@ model EventType {
|
|||
periodEndDate DateTime?
|
||||
periodDays Int?
|
||||
periodCountCalendarDays Boolean?
|
||||
lockTimeZoneToggleOnBookingPage Boolean @default(false)
|
||||
requiresConfirmation Boolean @default(false)
|
||||
requiresBookerEmailVerification Boolean @default(false)
|
||||
/// @zod.custom(imports.recurringEventType)
|
||||
|
@ -990,6 +991,7 @@ model TempOrgRedirect {
|
|||
// 0 would mean it is non org
|
||||
fromOrgId Int
|
||||
type RedirectType
|
||||
// It doesn't have any query params
|
||||
toUrl String
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
|
|
@ -11,6 +11,7 @@ export const baseEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
|||
hidden: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
requiresConfirmation: true,
|
||||
requiresBookerEmailVerification: true,
|
||||
});
|
||||
|
@ -28,6 +29,7 @@ export const bookEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
|||
periodStartDate: true,
|
||||
periodEndDate: true,
|
||||
recurringEvent: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
requiresConfirmation: true,
|
||||
requiresBookerEmailVerification: true,
|
||||
metadata: true,
|
||||
|
|
|
@ -30,6 +30,7 @@ const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
|||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
organizationId: true,
|
||||
});
|
||||
|
||||
const userEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
|
|
|
@ -266,7 +266,7 @@ export function getRegularOrDynamicEventType(
|
|||
}
|
||||
|
||||
export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
|
||||
const orgDetails = orgDomainConfig(ctx?.req?.headers.host ?? "");
|
||||
const orgDetails = orgDomainConfig(ctx?.req);
|
||||
if (process.env.INTEGRATION_TEST_MODE === "true") {
|
||||
logger.settings.minLevel = 2;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
|
@ -11,42 +10,12 @@ type ListOptions = {
|
|||
};
|
||||
|
||||
export const listHandler = async ({ ctx }: ListOptions) => {
|
||||
if (ctx.user?.organization?.id) {
|
||||
const membershipsWithoutParent = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
team: {
|
||||
parent: {
|
||||
is: {
|
||||
id: ctx.user?.organization?.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
inviteTokens: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { role: "desc" },
|
||||
});
|
||||
|
||||
const isOrgAdmin = !!(await isOrganisationAdmin(ctx.user.id, ctx.user.organization.id)); // Org id exists here as we're inside a conditional TS complaining for some reason
|
||||
|
||||
return membershipsWithoutParent.map(({ team: { inviteTokens, ..._team }, ...membership }) => ({
|
||||
role: membership.role,
|
||||
accepted: membership.accepted,
|
||||
isOrgAdmin,
|
||||
..._team,
|
||||
/** To prevent breaking we only return non-email attached token here, if we have one */
|
||||
inviteToken: inviteTokens.find((token) => token.identifier === `invite-link-for-teamId-${_team.id}`),
|
||||
}));
|
||||
}
|
||||
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
// Show all the teams this user belongs to regardless of the team being part of the user's org or not
|
||||
// We don't want to restrict in the listing here. If we need to restrict a situation where a user is part of the org along with being part of a non-org team, we should do that instead of filtering out from here
|
||||
// This became necessary when we started migrating user to Org, without migrating some teams of the user to the org
|
||||
// Also, we would allow a user to be part of multiple orgs, then also it would be necessary.
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
include: {
|
||||
|
|
|
@ -86,13 +86,16 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
|||
|
||||
const hasOrgsPlan = IS_SELF_HOSTED || ctx.user.organizationId;
|
||||
|
||||
const where: Prisma.EventTypeWhereInput = {};
|
||||
where.id = {
|
||||
in: activeOn,
|
||||
};
|
||||
if (userWorkflow.teamId) {
|
||||
//all children managed event types are added after
|
||||
where.parentId = null;
|
||||
}
|
||||
const activeOnEventTypes = await ctx.prisma.eventType.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: activeOn,
|
||||
},
|
||||
parentId: null,
|
||||
},
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
children: {
|
||||
|
|
|
@ -41,7 +41,7 @@ export function Avatar(props: AvatarProps) {
|
|||
<AvatarPrimitive.Root
|
||||
data-testid={props?.["data-testid"]}
|
||||
className={classNames(
|
||||
"bg-emphasis item-center relative inline-flex aspect-square justify-center rounded-full",
|
||||
"bg-emphasis item-center relative inline-flex aspect-square justify-center rounded-full align-top",
|
||||
indicator ? "overflow-visible" : "overflow-hidden",
|
||||
props.className,
|
||||
sizesPropsBySize[size]
|
||||
|
|
|
@ -36,7 +36,7 @@ export function DataTableToolbar<TData>({
|
|||
const isFiltered = table.getState().columnFilters.length > 0;
|
||||
|
||||
return (
|
||||
<div className="bg-default sticky top-[3rem] z-10 flex items-center justify-end space-x-2 py-[2.15rem] md:top-0">
|
||||
<div className="bg-default sticky top-[3rem] z-10 flex items-center justify-end space-x-2 py-4 md:top-0">
|
||||
{searchKey && (
|
||||
<Input
|
||||
className="max-w-64 mb-0 mr-auto rounded-md"
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
import { defineWorkspace } from "vitest/config";
|
||||
|
||||
const packagedEmbedTestsOnly = process.argv.includes("--packaged-embed-tests-only");
|
||||
const timeZoneDependentTestsOnly = process.argv.includes("--timeZoneDependentTestsOnly");
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
const envTZ = process.env.TZ;
|
||||
if (timeZoneDependentTestsOnly && !envTZ) {
|
||||
throw new Error("TZ environment variable is not set");
|
||||
}
|
||||
|
||||
// defineWorkspace provides a nice type hinting DX
|
||||
const workspaces = packagedEmbedTestsOnly
|
||||
? [
|
||||
|
@ -11,6 +18,19 @@ const workspaces = packagedEmbedTestsOnly
|
|||
},
|
||||
},
|
||||
]
|
||||
: // It doesn't seem to be possible to fake timezone per test, so we rerun the entire suite with different TZ. See https://github.com/vitest-dev/vitest/issues/1575#issuecomment-1439286286
|
||||
timeZoneDependentTestsOnly
|
||||
? [
|
||||
{
|
||||
test: {
|
||||
name: `TimezoneDependentTests:${envTZ}`,
|
||||
include: ["packages/**/*.timezone.test.ts", "apps/**/*.timezone.test.ts"],
|
||||
// TODO: Ignore the api until tests are fixed
|
||||
exclude: ["**/node_modules/**/*", "packages/embeds/**/*"],
|
||||
setupFiles: ["setupVitest.ts"],
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
test: {
|
||||
|
@ -20,6 +40,7 @@ const workspaces = packagedEmbedTestsOnly
|
|||
setupFiles: ["setupVitest.ts"],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
test: {
|
||||
name: "@calcom/closecom",
|
||||
|
|
35
yarn.lock
35
yarn.lock
|
@ -3550,7 +3550,7 @@ __metadata:
|
|||
postcss: ^8.4.18
|
||||
react: ^18.2.0
|
||||
react-dom: ^18.2.0
|
||||
tailwindcss: ^3.3.1
|
||||
tailwindcss: ^3.3.3
|
||||
typescript: ^4.9.4
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
@ -3644,7 +3644,7 @@ __metadata:
|
|||
"@calcom/ui": "*"
|
||||
"@headlessui/react": ^1.5.0
|
||||
"@heroicons/react": ^1.0.6
|
||||
"@prisma/client": ^5.3.0
|
||||
"@prisma/client": ^5.4.2
|
||||
"@tailwindcss/forms": ^0.5.2
|
||||
"@types/node": 16.9.1
|
||||
"@types/react": 18.0.26
|
||||
|
@ -8245,7 +8245,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@prisma/client@npm:^5.3.0, @prisma/client@npm:^5.4.2":
|
||||
"@prisma/client@npm:^5.4.2":
|
||||
version: 5.4.2
|
||||
resolution: "@prisma/client@npm:5.4.2"
|
||||
dependencies:
|
||||
|
@ -26866,13 +26866,20 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"libphonenumber-js@npm:^1.10.12":
|
||||
"libphonenumber-js@npm:1.10.12":
|
||||
version: 1.10.12
|
||||
resolution: "libphonenumber-js@npm:1.10.12"
|
||||
checksum: 8b8789b8b46f59e540108cdd3b925990e722a52134e03ab78a360312b7a001ab7f8211320338ddd60b8c68dd49d56075e16567f68aa22698e2218b69300fad42
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"libphonenumber-js@patch:libphonenumber-js@npm%3A1.10.12#./.yarn/patches/libphonenumber-js-npm-1.10.12-51c84f8bf1.patch::locator=calcom-monorepo%40workspace%3A.":
|
||||
version: 1.10.12
|
||||
resolution: "libphonenumber-js@patch:libphonenumber-js@npm%3A1.10.12#./.yarn/patches/libphonenumber-js-npm-1.10.12-51c84f8bf1.patch::version=1.10.12&hash=1f76a8&locator=calcom-monorepo%40workspace%3A."
|
||||
checksum: 9c0940ae91f19a6b69c42f685af0584b273bf7f96984ee9d7a26ec945b0473fb03546638a442cbb3ee1c76ad78801b57bec9d737b6b59ab681a909bec1edfbba
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"libqp@npm:2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "libqp@npm:2.0.1"
|
||||
|
@ -29412,7 +29419,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"next-i18next@npm:^13.2.2":
|
||||
"next-i18next@npm:13.3.0":
|
||||
version: 13.3.0
|
||||
resolution: "next-i18next@npm:13.3.0"
|
||||
dependencies:
|
||||
|
@ -29430,6 +29437,24 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"next-i18next@patch:next-i18next@npm%3A13.3.0#./.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch::locator=calcom-monorepo%40workspace%3A.":
|
||||
version: 13.3.0
|
||||
resolution: "next-i18next@patch:next-i18next@npm%3A13.3.0#./.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch::version=13.3.0&hash=bcbde7&locator=calcom-monorepo%40workspace%3A."
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.20.13
|
||||
"@types/hoist-non-react-statics": ^3.3.1
|
||||
core-js: ^3
|
||||
hoist-non-react-statics: ^3.3.2
|
||||
i18next-fs-backend: ^2.1.1
|
||||
peerDependencies:
|
||||
i18next: ^22.0.6
|
||||
next: ">= 12.0.0"
|
||||
react: ">= 17.0.2"
|
||||
react-i18next: ^12.2.0
|
||||
checksum: 7dcb7e2ec14a0164e2c803b5eb4be3d3198ff0db266fecd6225dfa99ec53bf923fe50230c413f2e9b9a795266fb4e31f129572865181df1eadcf8721ad138b3e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"next-seo@npm:^6.0.0":
|
||||
version: 6.1.0
|
||||
resolution: "next-seo@npm:6.1.0"
|
||||
|
|
Loading…
Reference in New Issue