Merge branch 'main' into platform

This commit is contained in:
Morgan Vernay 2023-12-13 10:36:01 +02:00
commit 104f6eb38a
194 changed files with 11765 additions and 8632 deletions

View File

@ -37,6 +37,7 @@ BASECAMP3_USER_AGENT=
DAILY_API_KEY=
DAILY_SCALE_PLAN=''
DAILY_WEBHOOK_SECRET=''
# - GOOGLE CALENDAR/MEET/LOGIN
# Needed to enable Google Calendar integration and Login with Google

View File

@ -196,6 +196,10 @@ EMAIL_SERVER_PORT=1025
# Make sure to run mailhog container manually or with `yarn dx`
E2E_TEST_MAILHOG_ENABLED=
# Resend
# Send transactional email using resend
# RESEND_API_KEY=
# **********************************************************************************************************
# Set the following value to true if you wish to enable Team Impersonation
@ -291,5 +295,14 @@ AB_TEST_BUCKET_PROBABILITY=50
# whether we redirect to the future/event-types from event-types or not
APP_ROUTER_EVENT_TYPES_ENABLED=1
APP_ROUTER_SETTINGS_ADMIN_ENABLED=1
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=1
APP_ROUTER_APPS_SLUG_ENABLED=1
APP_ROUTER_APPS_SLUG_SETUP_ENABLED=1
# whether we redirect to the future/apps/categories from /apps/categories or not
APP_ROUTER_APPS_CATEGORIES_ENABLED=1
# whether we redirect to the future/apps/categories/[category] from /apps/categories/[category] or not
APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=1
# api v2
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"

View File

@ -23,6 +23,7 @@ Fixes # (issue)
- [ ] Chore (refactoring code, technical debt, workflow improvements)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Tests (Unit/Integration/E2E or any other test)
- [ ] This change requires a documentation update
## How should this be tested?

View File

@ -1,15 +1,15 @@
diff --git a/index.cjs b/index.cjs
index b645707a3549fc298508726e404243499bbed499..f34b0891e99b275a9218e253f303f43d31ef3f73 100644
index c83f700ae9998cd87b4c2d66ecbb2ad3d7b4603c..76a2200b57f0b9243e2c61464d578b67746ad5a4 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.
// 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.

View File

@ -221,7 +221,6 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
1. Copy and paste your `DATABASE_URL` from `.env` to `.env.appStore`.
1. Set a 24 character random string in your `.env` file for the `CALENDSO_ENCRYPTION_KEY` (You can use a command like `openssl rand -base64 24` to generate one).
1. Set up the database using the Prisma schema (found in `packages/prisma/schema.prisma`)
In a development environment, run:

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,5 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
});

View File

@ -0,0 +1,6 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
});

View File

@ -1,6 +1,8 @@
const { withAxiom } = require("next-axiom");
const { withSentryConfig } = require("@sentry/nextjs");
module.exports = withAxiom({
const plugins = [withAxiom];
const nextConfig = {
transpilePackages: [
"@calcom/app-store",
"@calcom/core",
@ -66,4 +68,15 @@ module.exports = withAxiom({
],
};
},
});
};
if (!!process.env.NEXT_PUBLIC_SENTRY_DSN) {
nextConfig["sentry"] = {
autoInstrumentServerFunctions: true,
hideSourceMaps: true,
};
plugins.push(withSentryConfig);
}
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);

View File

@ -6,18 +6,44 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
async function authMiddleware(req: NextApiRequest) {
const { userId, prisma, isAdmin, query } = req;
if (isAdmin) {
return;
}
const { id } = schemaQueryIdParseInt.parse(query);
const userWithBookings = await prisma.user.findUnique({
const userWithBookingsAndTeamIds = await prisma.user.findUnique({
where: { id: userId },
include: { bookings: true },
include: {
bookings: true,
teams: {
select: {
teamId: true,
},
},
},
});
if (!userWithBookings) throw new HttpError({ statusCode: 404, message: "User not found" });
if (!userWithBookingsAndTeamIds) throw new HttpError({ statusCode: 404, message: "User not found" });
const userBookingIds = userWithBookings.bookings.map((booking) => booking.id);
const userBookingIds = userWithBookingsAndTeamIds.bookings.map((booking) => booking.id);
if (!isAdmin && !userBookingIds.includes(id)) {
throw new HttpError({ statusCode: 401, message: "You are not authorized" });
if (!userBookingIds.includes(id)) {
const teamBookings = await prisma.booking.findUnique({
where: {
id: id,
eventType: {
team: {
id: {
in: userWithBookingsAndTeamIds.teams.map((team) => team.teamId),
},
},
},
},
});
if (!teamBookings) {
throw new HttpError({ statusCode: 401, message: "You are not authorized" });
}
}
}

View File

@ -1,5 +1,9 @@
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { createContext } from "@calcom/trpc/server/createContext";
@ -9,10 +13,34 @@ import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util
import { TRPCError } from "@trpc/server";
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
// Apply plugins
dayjs.extend(utc);
dayjs.extend(timezone);
async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const input = getScheduleSchema.parse(req.query);
return await getAvailableSlots({ ctx: await createContext({ req, res }), input });
const { usernameList, ...rest } = req.query;
let slugs = usernameList;
if (!Array.isArray(usernameList)) {
slugs = usernameList ? [usernameList] : [];
}
const input = getScheduleSchema.parse({ usernameList: slugs, ...rest });
const timeZoneSupported = input.timeZone ? isSupportedTimeZone(input.timeZone) : false;
const availableSlots = await getAvailableSlots({ ctx: await createContext({ req, res }), input });
const slotsInProvidedTimeZone = timeZoneSupported
? Object.keys(availableSlots.slots).reduce(
(acc: Record<string, { time: string; attendees?: number; bookingUid?: string }[]>, date) => {
acc[date] = availableSlots.slots[date].map((slot) => ({
...slot,
time: dayjs(slot.time).tz(input.timeZone).format(),
}));
return acc;
},
{}
)
: availableSlots;
return slotsInProvidedTimeZone;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (cause) {
if (cause instanceof TRPCError) {

View File

@ -8,7 +8,9 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
storybook-static
storybook-static/*
!storybook-static/favicon.ico
!storybook-static/sb-cover.jpg
dist
dist-ssr
*.local

View File

@ -1,14 +1,16 @@
import { dirname, join } from "path";
import type { StorybookConfig } from "@storybook/nextjs";
import path, { dirname, join } from "path";
const path = require("path");
module.exports = {
const config: StorybookConfig = {
stories: [
"../intro.stories.mdx",
"../../../packages/ui/components/**/*.stories.mdx",
"../../../packages/platform/atoms/**/*.stories.mdx",
"../../../packages/features/**/*.stories.mdx",
"../../../packages/ui/components/**/*.stories.mdx", // legacy SB6 stories
"../../../packages/ui/components/**/*.stories.@(js|jsx|ts|tsx)",
"../../../packages/ui/components/**/*.docs.mdx",
"../../../packages/features/**/*.stories.@(js|jsx|ts|tsx)",
"../../../packages/features/**/*.docs.mdx",
"../../../packages/atoms/**/*.stories.@(js|jsx|ts|tsx)",
"../../../packages/atoms/**/*.docs.mdx",
],
addons: [
@ -17,23 +19,23 @@ module.exports = {
getAbsolutePath("@storybook/addon-interactions"),
getAbsolutePath("storybook-addon-rtl-direction"),
getAbsolutePath("storybook-react-i18next"),
getAbsolutePath("@storybook/addon-mdx-gfm"),
],
framework: {
name: getAbsolutePath("@storybook/nextjs"),
name: getAbsolutePath("@storybook/nextjs") as "@storybook/nextjs",
options: {
builder: {
fsCache: true,
lazyCompilation: true,
},
// builder: {
// fsCache: true,
// lazyCompilation: true,
// },
},
},
staticDirs: ["../public"],
webpackFinal: async (config, { configType }) => {
config.resolve = config.resolve || {};
config.resolve.fallback = {
fs: false,
assert: false,
@ -61,6 +63,8 @@ module.exports = {
zlib: false,
};
config.module = config.module || {};
config.module.rules = config.module.rules || [];
config.module.rules.push({
test: /\.css$/,
use: [
@ -85,6 +89,8 @@ module.exports = {
},
};
export default config;
function getAbsolutePath(value) {
return dirname(require.resolve(join(value, "package.json")));
}

View File

@ -1,44 +0,0 @@
import { I18nextProvider } from "react-i18next";
import "../styles/globals.css";
import "../styles/storybook-styles.css";
import i18n from "./i18next";
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
globals: {
locale: "en",
locales: {
en: "English",
fr: "Français",
},
},
i18n,
};
const withI18next = (Story) => (
<I18nextProvider i18n={i18n}>
<div style={{ margin: "2rem" }}>
<Story />
</div>
</I18nextProvider>
);
export const decorators = [withI18next];
window.getEmbedNamespace = () => {
const url = new URL(document.URL);
const namespace = url.searchParams.get("embed");
return namespace;
};
window.getEmbedTheme = () => {
return "auto";
};

View File

@ -0,0 +1,73 @@
// adds tooltip context to all stories
import { TooltipProvider } from "@radix-ui/react-tooltip";
import type { Preview } from "@storybook/react";
import React from "react";
import { I18nextProvider } from "react-i18next";
import type { EmbedThemeConfig } from "@calcom/embed-core/src/types";
// adds trpc context to all stories (esp. booker)
import { StorybookTrpcProvider } from "@calcom/ui";
import "../styles/globals.css";
import "../styles/storybook-styles.css";
import i18n from "./i18next";
const preview: Preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
globals: {
locale: "en",
locales: {
en: "English",
fr: "Français",
},
},
i18n,
nextjs: {
appDirectory: true,
},
},
decorators: [
(Story) => (
<StorybookTrpcProvider>
<TooltipProvider>
<I18nextProvider i18n={i18n}>
<div style={{ margin: "2rem" }}>
<Story />
</div>
</I18nextProvider>
</TooltipProvider>
</StorybookTrpcProvider>
),
],
};
export default preview;
declare global {
interface Window {
getEmbedNamespace: () => string | null;
getEmbedTheme: () => EmbedThemeConfig | null;
}
}
window.getEmbedNamespace = () => {
const url = new URL(document.URL);
const namespace = url.searchParams.get("embed");
return namespace;
};
window.getEmbedTheme = () => {
return "auto";
};

View File

@ -1,6 +1,6 @@
import { ArgsTable } from "@storybook/addon-docs";
import { SortType } from "@storybook/components";
import { PropDescriptor } from "@storybook/store";
import type { SortType } from "@storybook/blocks";
import type { PropDescriptor } from "@storybook/preview-api";
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ignore storybook addon types component as any so we have to do
type Component = any;

View File

@ -20,7 +20,10 @@
"@radix-ui/react-slider": "^1.0.0",
"@radix-ui/react-switch": "^1.0.0",
"@radix-ui/react-tooltip": "^1.0.0",
"@storybook/addon-docs": "^7.6.3",
"@storybook/blocks": "^7.6.3",
"@storybook/nextjs": "^7.6.3",
"@storybook/preview-api": "^7.6.3",
"next": "^13.4.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -33,7 +36,6 @@
"@storybook/addon-essentials": "^7.6.3",
"@storybook/addon-interactions": "^7.6.3",
"@storybook/addon-links": "^7.6.3",
"@storybook/addon-mdx-gfm": "^7.6.3",
"@storybook/nextjs": "^7.6.3",
"@storybook/react": "^7.6.3",
"@storybook/testing-library": "^0.2.2",

View File

@ -6,6 +6,11 @@ import z from "zod";
const ROUTES: [URLPattern, boolean][] = [
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
["/settings/admin/:path*", process.env.APP_ROUTER_SETTINGS_ADMIN_ENABLED === "1"] as const,
["/apps/installed/:category", process.env.APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED === "1"] as const,
["/apps/:slug", process.env.APP_ROUTER_APPS_SLUG_ENABLED === "1"] as const,
["/apps/:slug/setup", process.env.APP_ROUTER_APPS_SLUG_SETUP_ENABLED === "1"] as const,
["/apps/categories", process.env.APP_ROUTER_APPS_CATEGORIES_ENABLED === "1"] as const,
["/apps/categories/:category", process.env.APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED === "1"] as const,
].map(([pathname, enabled]) => [
new URLPattern({
pathname,

View File

@ -0,0 +1,212 @@
// originally from in the "experimental playground for tRPC + next.js 13" repo owned by trpc team
// file link: https://github.com/trpc/next-13/blob/main/%40trpc/next-layout/createTRPCNextLayout.ts
// repo link: https://github.com/trpc/next-13
// code is / will continue to be adapted for our usage
import { dehydrate, QueryClient } from "@tanstack/query-core";
import type { DehydratedState, QueryKey } from "@tanstack/react-query";
import type { Maybe, TRPCClientError, TRPCClientErrorLike } from "@calcom/trpc";
import {
callProcedure,
type AnyProcedure,
type AnyQueryProcedure,
type AnyRouter,
type DataTransformer,
type inferProcedureInput,
type inferProcedureOutput,
type inferRouterContext,
type MaybePromise,
type ProcedureRouterRecord,
} from "@calcom/trpc/server";
import { createRecursiveProxy, createFlatProxy } from "@trpc/server/shared";
export function getArrayQueryKey(
queryKey: string | [string] | [string, ...unknown[]] | unknown[],
type: string
): QueryKey {
const queryKeyArrayed = Array.isArray(queryKey) ? queryKey : [queryKey];
const [arrayPath, input] = queryKeyArrayed;
if (!input && (!type || type === "any")) {
return Array.isArray(arrayPath) && arrayPath.length !== 0 ? [arrayPath] : ([] as unknown as QueryKey);
}
return [
arrayPath,
{
...(typeof input !== "undefined" && { input: input }),
...(type && type !== "any" && { type: type }),
},
];
}
// copy starts
// copied from trpc/trpc repo
// ref: https://github.com/trpc/trpc/blob/main/packages/next/src/withTRPC.tsx#L37-#L58
function transformQueryOrMutationCacheErrors<
TState extends DehydratedState["queries"][0] | DehydratedState["mutations"][0]
>(result: TState): TState {
const error = result.state.error as Maybe<TRPCClientError<any>>;
if (error instanceof Error && error.name === "TRPCClientError") {
const newError: TRPCClientErrorLike<any> = {
message: error.message,
data: error.data,
shape: error.shape,
};
return {
...result,
state: {
...result.state,
error: newError,
},
};
}
return result;
}
// copy ends
interface CreateTRPCNextLayoutOptions<TRouter extends AnyRouter> {
router: TRouter;
createContext: () => MaybePromise<inferRouterContext<TRouter>>;
transformer?: DataTransformer;
}
/**
* @internal
*/
export type DecorateProcedure<TProcedure extends AnyProcedure> = TProcedure extends AnyQueryProcedure
? {
fetch(input: inferProcedureInput<TProcedure>): Promise<inferProcedureOutput<TProcedure>>;
fetchInfinite(input: inferProcedureInput<TProcedure>): Promise<inferProcedureOutput<TProcedure>>;
prefetch(input: inferProcedureInput<TProcedure>): Promise<inferProcedureOutput<TProcedure>>;
prefetchInfinite(input: inferProcedureInput<TProcedure>): Promise<inferProcedureOutput<TProcedure>>;
}
: never;
type OmitNever<TType> = Pick<
TType,
{
[K in keyof TType]: TType[K] extends never ? never : K;
}[keyof TType]
>;
/**
* @internal
*/
export type DecoratedProcedureRecord<
TProcedures extends ProcedureRouterRecord,
TPath extends string = ""
> = OmitNever<{
[TKey in keyof TProcedures]: TProcedures[TKey] extends AnyRouter
? DecoratedProcedureRecord<TProcedures[TKey]["_def"]["record"], `${TPath}${TKey & string}.`>
: TProcedures[TKey] extends AnyQueryProcedure
? DecorateProcedure<TProcedures[TKey]>
: never;
}>;
type CreateTRPCNextLayout<TRouter extends AnyRouter> = DecoratedProcedureRecord<TRouter["_def"]["record"]> & {
dehydrate(): Promise<DehydratedState>;
queryClient: QueryClient;
};
const getStateContainer = <TRouter extends AnyRouter>(opts: CreateTRPCNextLayoutOptions<TRouter>) => {
let _trpc: {
queryClient: QueryClient;
context: inferRouterContext<TRouter>;
} | null = null;
return () => {
if (_trpc === null) {
_trpc = {
context: opts.createContext(),
queryClient: new QueryClient(),
};
}
return _trpc;
};
};
export function createTRPCNextLayout<TRouter extends AnyRouter>(
opts: CreateTRPCNextLayoutOptions<TRouter>
): CreateTRPCNextLayout<TRouter> {
const getState = getStateContainer(opts);
const transformer = opts.transformer ?? {
serialize: (v) => v,
deserialize: (v) => v,
};
return createFlatProxy((key) => {
const state = getState();
const { queryClient } = state;
if (key === "queryClient") {
return queryClient;
}
if (key === "dehydrate") {
// copy starts
// copied from trpc/trpc repo
// ref: https://github.com/trpc/trpc/blob/main/packages/next/src/withTRPC.tsx#L214-#L229
const dehydratedCache = dehydrate(queryClient, {
shouldDehydrateQuery() {
// makes sure errors are also dehydrated
return true;
},
});
// since error instances can't be serialized, let's make them into `TRPCClientErrorLike`-objects
const dehydratedCacheWithErrors = {
...dehydratedCache,
queries: dehydratedCache.queries.map(transformQueryOrMutationCacheErrors),
mutations: dehydratedCache.mutations.map(transformQueryOrMutationCacheErrors),
};
return () => transformer.serialize(dehydratedCacheWithErrors);
}
// copy ends
return createRecursiveProxy(async (callOpts) => {
const path = [key, ...callOpts.path];
const utilName = path.pop();
const ctx = await state.context;
const caller = opts.router.createCaller(ctx);
const pathStr = path.join(".");
const input = callOpts.args[0];
if (utilName === "fetchInfinite") {
return queryClient.fetchInfiniteQuery(getArrayQueryKey([path, input], "infinite"), () =>
caller.query(pathStr, input)
);
}
if (utilName === "prefetch") {
return queryClient.prefetchQuery({
queryKey: getArrayQueryKey([path, input], "query"),
queryFn: async () => {
const res = await callProcedure({
procedures: opts.router._def.procedures,
path: pathStr,
rawInput: input,
ctx,
type: "query",
});
return res;
},
});
}
if (utilName === "prefetchInfinite") {
return queryClient.prefetchInfiniteQuery(getArrayQueryKey([path, input], "infinite"), () =>
caller.query(pathStr, input)
);
}
return queryClient.fetchQuery(getArrayQueryKey([path, input], "query"), () =>
caller.query(pathStr, input)
);
}) as CreateTRPCNextLayout<TRouter>;
});
}

View File

@ -0,0 +1,34 @@
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { headers } from "next/headers";
import superjson from "superjson";
import { CALCOM_VERSION } from "@calcom/lib/constants";
import prisma, { readonlyPrisma } from "@calcom/prisma";
import { appRouter } from "@calcom/trpc/server/routers/_app";
import { createTRPCNextLayout } from "./createTRPCNextLayout";
export async function ssgInit() {
const locale = headers().get("x-locale") ?? "en";
const i18n = (await serverSideTranslations(locale, ["common"])) || "en";
const ssg = createTRPCNextLayout({
router: appRouter,
transformer: superjson,
createContext() {
return { prisma, insightsDb: readonlyPrisma, session: null, 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" },
];
ssg.queryClient.setQueryData(queryKey, { i18n });
return ssg;
}

View File

@ -0,0 +1,57 @@
import { type GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { headers, cookies } from "next/headers";
import superjson from "superjson";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { CALCOM_VERSION } from "@calcom/lib/constants";
import prisma, { readonlyPrisma } from "@calcom/prisma";
import { appRouter } from "@calcom/trpc/server/routers/_app";
import { createTRPCNextLayout } from "./createTRPCNextLayout";
export async function ssrInit(options?: { noI18nPreload: boolean }) {
const req = {
headers: headers(),
cookies: cookies(),
};
const locale = await getLocale(req);
const i18n = (await serverSideTranslations(locale, ["common", "vital"])) || "en";
const ssr = createTRPCNextLayout({
router: appRouter,
transformer: superjson,
createContext() {
return {
prisma,
insightsDb: readonlyPrisma,
session: null,
locale,
i18n,
req: req as unknown as GetServerSidePropsContext["req"],
};
},
});
// 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([
// So feature flags are available on first render
ssr.viewer.features.map.prefetch(),
// Provides a better UX to the users who have already upgraded.
ssr.viewer.teams.hasTeamPlan.prefetch(),
ssr.viewer.public.session.prefetch(),
]);
return ssr;
}

View File

@ -0,0 +1,15 @@
import { type ReactElement } from "react";
import PageWrapper from "@components/PageWrapperAppDir";
type EventTypesLayoutProps = {
children: ReactElement;
};
export default function Layout({ children }: EventTypesLayoutProps) {
return (
<PageWrapper getLayout={null} requiresLicense={false} nonce={undefined} themeBasis={null}>
{children}
</PageWrapper>
);
}

View File

@ -0,0 +1,126 @@
import AppPage from "@pages/apps/[slug]/index";
import { Prisma } from "@prisma/client";
import { _generateMetadata } from "app/_utils";
import fs from "fs";
import matter from "gray-matter";
import { notFound } from "next/navigation";
import path from "path";
import { z } from "zod";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import { getAppAssetFullPath } from "@calcom/app-store/getAppAssetFullPath";
import { APP_NAME, IS_PRODUCTION } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
const sourceSchema = z.object({
content: z.string(),
data: z.object({
description: z.string().optional(),
items: z
.array(
z.union([
z.string(),
z.object({
iframe: z.object({ src: z.string() }),
}),
])
)
.optional(),
}),
});
export const generateMetadata = async ({ params }: { params: Record<string, string | string[]> }) => {
const { data } = await getPageProps({ params });
return await _generateMetadata(
() => `${data.name} | ${APP_NAME}`,
() => data.description
);
};
export const generateStaticParams = async () => {
try {
const appStore = await prisma.app.findMany({ select: { slug: true } });
return appStore.map(({ slug }) => ({ slug }));
} catch (e: unknown) {
if (e instanceof Prisma.PrismaClientInitializationError) {
// Database is not available at build time, but that's ok we fall back to resolving paths on demand
} else {
throw e;
}
}
return [];
};
const getPageProps = async ({ params }: { params: Record<string, string | string[]> }) => {
if (typeof params?.slug !== "string") {
notFound();
}
const appMeta = await getAppWithMetadata({
slug: params?.slug,
});
const appFromDb = await prisma.app.findUnique({
where: { slug: params.slug.toLowerCase() },
});
const isAppAvailableInFileSystem = appMeta;
const isAppDisabled = isAppAvailableInFileSystem && (!appFromDb || !appFromDb.enabled);
if (!IS_PRODUCTION && isAppDisabled) {
return {
isAppDisabled: true as const,
data: {
...appMeta,
},
};
}
if (!appFromDb || !appMeta || isAppDisabled) {
notFound();
}
const isTemplate = appMeta.isTemplate;
const appDirname = path.join(isTemplate ? "templates" : "", appFromDb.dirName);
const README_PATH = path.join(process.cwd(), "..", "..", `packages/app-store/${appDirname}/DESCRIPTION.md`);
const postFilePath = path.join(README_PATH);
let source = "";
try {
source = fs.readFileSync(postFilePath).toString();
source = source.replace(/{DESCRIPTION}/g, appMeta.description);
} catch (error) {
/* If the app doesn't have a README we fallback to the package description */
console.log(`No DESCRIPTION.md provided for: ${appDirname}`);
source = appMeta.description;
}
const result = matter(source);
const { content, data } = sourceSchema.parse({ content: result.content, data: result.data });
if (data.items) {
data.items = data.items.map((item) => {
if (typeof item === "string") {
return getAppAssetFullPath(item, {
dirName: appMeta.dirName,
isTemplate: appMeta.isTemplate,
});
}
return item;
});
}
return {
isAppDisabled: false as const,
source: { content, data },
data: appMeta,
};
};
export default async function Page({ params }: { params: Record<string, string | string[]> }) {
const pageProps = await getPageProps({ params });
return <AppPage {...pageProps} />;
}
export const dynamic = "force-static";

View File

@ -0,0 +1,36 @@
import SetupPage from "@pages/apps/[slug]/setup";
import { _generateMetadata } from "app/_utils";
import type { GetServerSidePropsContext } from "next";
import { cookies, headers } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
import { APP_NAME } from "@calcom/lib/constants";
export const generateMetadata = async ({ params }: { params: Record<string, string | string[]> }) => {
return await _generateMetadata(
() => `${params.slug} | ${APP_NAME}`,
() => ""
);
};
const getPageProps = async ({ params }: { params: Record<string, string | string[]> }) => {
const req = { headers: headers(), cookies: cookies() };
const result = await getServerSideProps({ params, req } as unknown as GetServerSidePropsContext);
if (!result || "notFound" in result) {
notFound();
}
if ("redirect" in result) {
redirect(result.redirect.destination);
}
return result.props;
};
export default async function Page({ params }: { params: Record<string, string | string[]> }) {
const pageProps = await getPageProps({ params });
return <SetupPage {...pageProps} />;
}

View File

@ -0,0 +1,79 @@
import CategoryPage from "@pages/apps/categories/[category]";
import { Prisma } from "@prisma/client";
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import z from "zod";
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
import { APP_NAME } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { AppCategories } from "@calcom/prisma/enums";
import PageWrapper from "@components/PageWrapperAppDir";
export const generateMetadata = async () => {
return await _generateMetadata(
() => `${APP_NAME} | ${APP_NAME}`,
() => ""
);
};
export const generateStaticParams = async () => {
const paths = Object.keys(AppCategories);
try {
await prisma.$queryRaw`SELECT 1`;
} catch (e: unknown) {
if (e instanceof Prisma.PrismaClientInitializationError) {
// Database is not available at build time. Make sure we fall back to building these pages on demand
return [];
} else {
throw e;
}
}
return paths.map((category) => ({ category }));
};
const querySchema = z.object({
category: z.nativeEnum(AppCategories),
});
const getPageProps = async ({ params }: { params: Record<string, string | string[]> }) => {
const p = querySchema.safeParse(params);
if (!p.success) {
return notFound();
}
const appQuery = await prisma.app.findMany({
where: {
categories: {
has: p.data.category,
},
},
select: {
slug: true,
},
});
const dbAppsSlugs = appQuery.map((category) => category.slug);
const appStore = await getAppRegistry();
const apps = appStore.filter((app) => dbAppsSlugs.includes(app.slug));
return {
apps,
};
};
export default async function Page({ params }: { params: Record<string, string | string[]> }) {
const { apps } = await getPageProps({ params });
return (
<PageWrapper getLayout={null} requiresLicense={false} nonce={undefined} themeBasis={null}>
<CategoryPage apps={apps} />
</PageWrapper>
);
}
export const dynamic = "force-static";

View File

@ -0,0 +1,56 @@
import LegacyPage from "@pages/apps/categories/index";
import { ssrInit } from "app/_trpc/ssrInit";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import PageWrapper from "@components/PageWrapperAppDir";
export const generateMetadata = async () => {
return await _generateMetadata(
() => `Categories | ${APP_NAME}`,
() => ""
);
};
async function getPageProps() {
const ssr = await ssrInit();
const req = { headers: headers(), cookies: cookies() };
// @ts-expect-error Type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to type 'NextApiRequest | IncomingMessage
const session = await getServerSession({ req });
let appStore;
if (session?.user?.id) {
appStore = await getAppRegistryWithCredentials(session.user.id);
} else {
appStore = await getAppRegistry();
}
const categories = appStore.reduce((c, app) => {
for (const category of app.categories) {
c[category] = c[category] ? c[category] + 1 : 1;
}
return c;
}, {} as Record<string, number>);
return {
categories: Object.entries(categories).map(([name, count]) => ({ name, count })),
dehydratedState: await ssr.dehydrate(),
};
}
export default async function Page() {
const props = await getPageProps();
const h = headers();
const nonce = h.get("x-nonce") ?? undefined;
return (
<PageWrapper getLayout={null} requiresLicense={false} nonce={nonce} themeBasis={null} {...props}>
<LegacyPage {...props} />
</PageWrapper>
);
}

View File

@ -0,0 +1,15 @@
import { type ReactElement } from "react";
import PageWrapper from "@components/PageWrapperAppDir";
type EventTypesLayoutProps = {
children: ReactElement;
};
export default function Layout({ children }: EventTypesLayoutProps) {
return (
<PageWrapper getLayout={null} requiresLicense={false} nonce={undefined} themeBasis={null}>
{children}
</PageWrapper>
);
}

View File

@ -0,0 +1,36 @@
import LegacyPage from "@pages/apps/installed/[category]";
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { z } from "zod";
import { APP_NAME } from "@calcom/lib/constants";
import { AppCategories } from "@calcom/prisma/enums";
const querySchema = z.object({
category: z.nativeEnum(AppCategories),
});
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${t("installed_apps")} | ${APP_NAME}`,
(t) => t("manage_your_connected_apps")
);
};
const getPageProps = async ({ params }: { params: Record<string, string | string[]> }) => {
const p = querySchema.safeParse(params);
if (!p.success) {
return notFound();
}
return {
category: p.data.category,
};
};
export default async function Page({ params }: { params: Record<string, string | string[]> }) {
const { category } = await getPageProps({ params });
return <LegacyPage />;
}

View File

@ -1,13 +1,11 @@
import Page from "@pages/settings/admin/organizations/index";
import { _generateMetadata } from "app/_utils";
import Page from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgPage";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("organizations"),
(t) => t("orgs_page_description")
);
export default function AppPage() {
// @ts-expect-error FIXME Property 'Component' is incompatible with index signature
return <Page />;
}
export default Page;

View File

@ -1,10 +1,10 @@
import Page from "@pages/settings/admin/users/[id]/edit";
import { getServerCaller } from "app/_trpc/serverClient";
import { type Params } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { z } from "zod";
import Page from "@calcom/features/ee/users/pages/users-edit-view";
import prisma from "@calcom/prisma";
const userIdSchema = z.object({ id: z.coerce.number() });
@ -33,7 +33,4 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
);
};
export default function AppPage() {
// @ts-expect-error FIXME AppProps | undefined' does not satisfy the constraint 'PageProps'
return <Page />;
}
export default Page;

View File

@ -1,13 +1,11 @@
import Page from "@pages/settings/admin/users/add";
import { _generateMetadata } from "app/_utils";
import Page from "@calcom/features/ee/users/pages/users-add-view";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Add new user",
() => "Here you can add a new user."
);
export default function AppPage() {
// @ts-expect-error FIXME AppProps | undefined' does not satisfy the constraint 'PageProps'
return <Page />;
}
export default Page;

View File

@ -1,13 +1,11 @@
import Page from "@pages/settings/admin/users/index";
import { _generateMetadata } from "app/_utils";
import Page from "@calcom/features/ee/users/pages/users-listing-view";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Users",
() => "A list of all the users in your account including their name, title, email and role."
);
export default function AppPage() {
// @ts-expect-error FIXME Property 'Component' is incompatible with index signature
return <Page />;
}
export default Page;

View File

@ -1,4 +1,6 @@
import { dir } from "i18next";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
import { headers, cookies } from "next/headers";
import Script from "next/script";
import React from "react";
@ -10,6 +12,14 @@ import { prepareRootMetadata } from "@lib/metadata";
import "../styles/globals.css";
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: "block",
});
export const generateMetadata = () =>
prepareRootMetadata({
twitterCreator: "@calcom",
@ -66,6 +76,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
src="https://snippet.meticulous.ai/v1/stagingMeticulousSnippet.js"
/>
)}
<style>{`
:root {
--font-inter: ${interFont.style.fontFamily.replace(/\'/g, "")};
--font-cal: ${calFont.style.fontFamily.replace(/\'/g, "")};
}
`}</style>
</head>
<body
className="dark:bg-darkgray-50 desktop-transparent bg-subtle antialiased"

View File

@ -2,8 +2,6 @@
import { type DehydratedState } from "@tanstack/react-query";
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";
@ -20,14 +18,6 @@ export interface CalPageWrapper {
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) | null;
children: React.ReactElement;
@ -71,13 +61,6 @@ function PageWrapper(props: PageWrapperProps) {
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
)}

View File

@ -382,7 +382,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
}}
/>
{selectedLocation && LocationOptions}
<DialogFooter className="mt-4">
<DialogFooter className="relative">
<Button
onClick={() => {
setShowLocationModal(false);

View File

@ -357,7 +357,6 @@ export const EventSetupTab = (
<div className="flex">
<LocationSelect
defaultMenuIsOpen={showEmptyLocationSelect}
autoFocus
placeholder={t("select")}
options={locationOptions}
value={selectedNewOption}

View File

@ -156,11 +156,8 @@ const UserProfile = () => {
{t("few_sentences_about_yourself")}
</p>
</fieldset>
<Button
type="submit"
className="text-inverted mt-8 flex w-full flex-row justify-center rounded-md border border-black bg-black p-2 text-center text-sm">
<Button EndIcon={ArrowRight} type="submit" className="mt-8 w-full items-center justify-center">
{t("finish")}
<ArrowRight className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
</Button>
</form>
);

View File

@ -9,7 +9,7 @@ import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import { Button, TimezoneSelect } from "@calcom/ui";
import { Button, TimezoneSelect, Input } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
@ -76,7 +76,7 @@ const UserSettings = (props: IUserSettingsProps) => {
<label htmlFor="name" className="text-default mb-2 block text-sm font-medium">
{t("full_name")}
</label>
<input
<Input
{...register("name", {
required: true,
})}
@ -85,7 +85,6 @@ const UserSettings = (props: IUserSettingsProps) => {
type="text"
autoComplete="off"
autoCorrect="off"
className="border-default w-full rounded-md border text-sm"
/>
{errors.name && (
<p data-testid="required" className="py-2 text-xs text-red-500">
@ -106,7 +105,7 @@ const UserSettings = (props: IUserSettingsProps) => {
className="mt-2 w-full rounded-md text-sm"
/>
<p className="text-subtle dark:text-inverted mt-3 flex flex-row font-sans text-xs leading-tight">
<p className="text-subtle mt-3 flex flex-row font-sans text-xs leading-tight">
{t("current_time")} {dayjs().tz(selectedTimeZone).format("LT").toString().toLowerCase()}
</p>
</div>

View File

@ -97,13 +97,9 @@ const CustomI18nextProvider = (props: { children: React.ReactElement; i18n?: SSR
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 }}>
<AppWithTranslationHoc pageProps={{ _nextI18Next: i18n?._nextI18Next }}>
{props.children}
</AppWithTranslationHoc>
);

View File

@ -64,6 +64,23 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
requestHeaders.set("x-csp-enforce", "true");
}
if (url.pathname.startsWith("/future/apps/installed")) {
const returnTo = req.cookies.get("return-to")?.value;
if (returnTo !== undefined) {
requestHeaders.set("Set-Cookie", "return-to=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT");
let validPathname = returnTo;
try {
validPathname = new URL(returnTo).pathname;
} catch (e) {}
const nextUrl = url.clone();
nextUrl.pathname = validPathname;
return NextResponse.redirect(nextUrl, { headers: requestHeaders });
}
}
requestHeaders.set("x-pathname", url.pathname);
const locale = await getLocale(req);
@ -103,6 +120,16 @@ export const config = {
"/future/event-types/",
"/settings/admin/:path*",
"/future/settings/admin/:path*",
"/apps/installed/:category/",
"/future/apps/installed/:category/",
"/apps/:slug/",
"/future/apps/:slug/",
"/apps/:slug/setup/",
"/future/apps/:slug/setup/",
"/apps/categories/",
"/future/apps/categories/",
"/apps/categories/:category/",
"/future/apps/categories/:category/",
],
};

View File

@ -154,11 +154,14 @@ const matcherConfigUserTypeEmbedRoute = {
/** @type {import("next").NextConfig} */
const nextConfig = {
experimental: {
serverComponentsExternalPackages: ["next-i18next"],
},
i18n: {
...i18n,
localeDetection: false,
},
productionBrowserSourceMaps: true,
productionBrowserSourceMaps: false,
/* We already do type check on GH actions */
typescript: {
ignoreBuildErrors: !!process.env.CI,
@ -517,6 +520,11 @@ const nextConfig = {
destination: "/apps/installed/conferencing",
permanent: true,
},
{
source: "/apps/installed",
destination: "/apps/installed/calendar",
permanent: true,
},
// OAuth callbacks when sent to localhost:3000(w would be expected) should be redirected to corresponding to WEBAPP_URL
...(process.env.NODE_ENV === "development" &&
// Safer to enable the redirect only when the user is opting to test out organizations

View File

@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.5.4",
"version": "3.5.5",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@ -83,7 +83,7 @@
"ics": "^2.37.0",
"jose": "^4.13.1",
"kbar": "^0.1.0-beta.36",
"libphonenumber-js": "^1.10.12",
"libphonenumber-js": "^1.10.51",
"lodash": "^4.17.21",
"lottie-react": "^2.3.1",
"markdown-it": "^13.0.1",

View File

@ -1,14 +1,11 @@
import type { GetServerSidePropsContext } from "next";
import { useSearchParams } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import {
getBookingForReschedule,
getBookingForSeatedEvent,
getMultipleDurationValue,
} from "@calcom/features/bookings/lib/get-booking";
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getUsernameList } from "@calcom/lib/defaultEvents";
@ -26,6 +23,16 @@ import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
export const getMultipleDurationValue = (
multipleDurationConfig: number[] | undefined,
queryDuration: string | string[] | null | undefined,
defaultValue: number
) => {
if (!multipleDurationConfig) return null;
if (multipleDurationConfig.includes(Number(queryDuration))) return Number(queryDuration);
return defaultValue;
};
export default function Type({
slug,
user,
@ -35,9 +42,10 @@ export default function Type({
isBrandingHidden,
isSEOIndexable,
rescheduleUid,
entity,
duration,
eventData,
}: PageProps) {
const searchParams = useSearchParams();
return (
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
<BookerSeo
@ -46,7 +54,7 @@ export default function Type({
rescheduleUid={rescheduleUid ?? undefined}
hideBranding={isBrandingHidden}
isSEOIndexable={isSEOIndexable ?? true}
entity={entity}
entity={eventData.entity}
bookingData={booking}
/>
<Booker
@ -55,8 +63,16 @@ export default function Type({
bookingData={booking}
isAway={away}
hideBranding={isBrandingHidden}
entity={entity}
duration={duration}
entity={eventData.entity}
durationConfig={eventData.metadata?.multipleDuration}
/* TODO: Currently unused, evaluate it is needed-
* Possible alternative approach is to have onDurationChange.
*/
duration={getMultipleDurationValue(
eventData.metadata?.multipleDuration,
searchParams?.get("duration"),
eventData.length
)}
/>
</main>
);
@ -68,7 +84,7 @@ Type.PageWrapper = PageWrapper;
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
const session = await getServerSession(context);
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
const { rescheduleUid, bookingUid } = context.query;
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
@ -120,12 +136,14 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
return {
props: {
entity: eventData.entity,
duration: getMultipleDurationValue(
eventData.metadata?.multipleDuration,
queryDuration,
eventData.length
),
eventData: {
entity: eventData.entity,
length: eventData.length,
metadata: {
...eventData.metadata,
multipleDuration: [15, 30, 60],
},
},
booking,
user: usernames.join("+"),
slug,
@ -144,7 +162,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const session = await getServerSession(context);
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
const username = usernames[0];
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
const { rescheduleUid, bookingUid } = context.query;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
@ -207,15 +225,14 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
return {
props: {
booking,
duration: getMultipleDurationValue(
eventData.metadata?.multipleDuration,
queryDuration,
eventData.length
),
eventData: {
entity: eventData.entity,
length: eventData.length,
metadata: eventData.metadata,
},
away: user?.away,
user: username,
slug,
entity: eventData.entity,
trpcState: ssr.dehydrate(),
isBrandingHidden: user?.hideBranding,
isSEOIndexable: user?.allowSEOIndexing,

View File

@ -6,8 +6,18 @@ import { type RequestWithUsernameStatus } from "@calcom/features/auth/signup/use
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { signupSchema } from "@calcom/prisma/zod-utils";
function ensureSignupIsEnabled(req: RequestWithUsernameStatus) {
const { token } = signupSchema
.pick({
token: true,
})
.parse(req.body);
// Stil allow signups if there is a team invite
if (token) return;
function ensureSignupIsEnabled() {
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true") {
throw new HttpError({
statusCode: 403,
@ -29,7 +39,7 @@ export default async function handler(req: RequestWithUsernameStatus, res: NextA
// Use a try catch instead of returning res every time
try {
ensureReqIsPost(req);
ensureSignupIsEnabled();
ensureSignupIsEnabled(req);
/**
* Im not sure its worth merging these two handlers. They are different enough to be separate.

View File

@ -1,24 +1,38 @@
import type { WebhookTriggerEvents } from "@prisma/client";
import { createHmac } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { DailyLocationType } from "@calcom/app-store/locations";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient";
import { sendDailyVideoRecordingEmails } from "@calcom/emails";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { defaultHandler } from "@calcom/lib/server";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
const schema = z.object({
recordingId: z.string(),
bookingUID: z.string(),
});
const schema = z
.object({
version: z.string(),
type: z.string(),
id: z.string(),
payload: z.object({
recording_id: z.string(),
end_ts: z.number(),
room_name: z.string(),
start_ts: z.number(),
status: z.string(),
max_participants: z.number(),
duration: z.number(),
s3_key: z.string(),
}),
event_ts: z.number(),
})
.passthrough();
const downloadLinkSchema = z.object({
download_link: z.string(),
@ -39,8 +53,8 @@ const triggerWebhook = async ({
};
}) => {
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
// Send Webhook call if hooked to BOOKING.RECORDING_READY
// Send Webhook call if hooked to BOOKING.RECORDING_READY
const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId);
const subscriberOptions = {
@ -62,71 +76,62 @@ const triggerWebhook = async ({
await Promise.all(promises);
};
const checkIfUserIsPartOfTheSameTeam = async (
teamId: number | undefined | null,
userId: number,
userEmail: string | undefined | null
) => {
if (!teamId) return false;
const getUserQuery = () => {
if (!!userEmail) {
return {
OR: [
{
id: userId,
},
{
email: userEmail,
},
],
};
} else {
return {
id: userId,
};
}
};
const team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
user: getUserQuery(),
},
},
},
});
return !!team;
};
const testRequestSchema = z.object({
test: z.enum(["test"]),
});
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
return res.status(405).json({ message: "No SendGrid API key or email" });
}
const response = schema.safeParse(JSON.parse(req.body));
if (!response.success) {
if (testRequestSchema.safeParse(req.body).success) {
return res.status(200).json({ message: "Test request successful" });
}
const hmacSecret = process.env.DAILY_WEBHOOK_SECRET;
if (!hmacSecret) {
return res.status(405).json({ message: "No Daily Webhook Secret" });
}
const signature = `${req.headers["x-webhook-timestamp"]}.${JSON.stringify(req.body)}`;
const base64DecodedSecret = Buffer.from(hmacSecret, "base64");
const hmac = createHmac("sha256", base64DecodedSecret);
const computed_signature = hmac.update(signature).digest("base64");
if (req.headers["x-webhook-signature"] !== computed_signature) {
return res.status(403).json({ message: "Signature does not match" });
}
const response = schema.safeParse(req.body);
if (!response.success || response.data.type !== "recording.ready-to-download") {
return res.status(400).send({
message: "Invalid Payload",
});
}
const { recordingId, bookingUID } = response.data;
const session = await getServerSession({ req, res });
const { room_name, recording_id, status } = response.data.payload;
if (!session?.user) {
return res.status(401).send({
message: "User not logged in",
if (status !== "finished") {
return res.status(400).send({
message: "Recording not finished",
});
}
try {
const booking = await prisma.booking.findFirst({
const bookingReference = await prisma.bookingReference.findFirst({
where: { type: "daily_video", uid: room_name, meetingId: room_name },
select: { bookingId: true },
});
if (!bookingReference || !bookingReference.bookingId) {
return res.status(404).send({ message: "Booking reference not found" });
}
const booking = await prisma.booking.findUniqueOrThrow({
where: {
uid: bookingUID,
id: bookingReference.bookingId,
},
select: {
...bookingMinimalSelect,
@ -153,9 +158,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
},
});
if (!booking || booking.location !== DailyLocationType) {
if (!booking || !(booking.location === DailyLocationType || booking?.location?.trim() === "")) {
return res.status(404).send({
message: `Booking of uid ${bookingUID} does not exist or does not contain daily video as location`,
message: `Booking of room_name ${room_name} does not exist or does not contain daily video as location`,
});
}
@ -175,26 +180,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const attendeesList = await Promise.all(attendeesListPromises);
const isUserAttendeeOrOrganiser =
booking?.user?.id === session.user.id ||
attendeesList.find(
(attendee) => attendee.id === session.user.id || attendee.email === session.user.email
);
if (!isUserAttendeeOrOrganiser) {
const isUserMemberOfTheTeam = checkIfUserIsPartOfTheSameTeam(
booking?.eventType?.teamId,
session.user.id,
session.user.email
);
if (!isUserMemberOfTheTeam) {
return res.status(403).send({
message: "Unauthorised",
});
}
}
await prisma.booking.update({
where: {
uid: booking.uid,
@ -204,7 +189,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
},
});
const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId);
const response = await getDownloadLinkOfCalVideoByRecordingId(recording_id);
const downloadLinkResponse = downloadLinkSchema.parse(response);
const downloadLink = downloadLinkResponse.download_link;
@ -242,17 +227,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
},
});
const isSendingEmailsAllowed = IS_SELF_HOSTED || session?.user?.belongsToActiveTeam;
// send emails to all attendees only when user has team plan
if (isSendingEmailsAllowed) {
await sendDailyVideoRecordingEmails(evt, downloadLink);
return res.status(200).json({ message: "Success" });
}
return res.status(403).json({ message: "User does not have team plan to send out emails" });
await sendDailyVideoRecordingEmails(evt, downloadLink);
return res.status(200).json({ message: "Success" });
} catch (err) {
console.warn("Error in /recorded-daily-video", err);
console.error("Error in /recorded-daily-video", err);
return res.status(500).json({ message: "something went wrong" });
}
}

View File

@ -1,3 +1,5 @@
"use client";
import { Prisma } from "@prisma/client";
import fs from "fs";
import matter from "gray-matter";

View File

@ -1,3 +1,5 @@
"use client";
import type { InferGetServerSidePropsType } from "next";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";

View File

@ -1,3 +1,5 @@
"use client";
import { Prisma } from "@prisma/client";
import type { GetStaticPropsContext, InferGetStaticPropsType } from "next";
import Link from "next/link";

View File

@ -1,3 +1,5 @@
"use client";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
@ -13,7 +15,7 @@ import PageWrapper from "@components/PageWrapper";
import { ssrInit } from "@server/lib/ssr";
export default function Apps({ categories }: inferSSRProps<typeof getServerSideProps>) {
export default function Apps({ categories }: Omit<inferSSRProps<typeof getServerSideProps>, "trpcState">) {
const { t, isLocaleReady } = useLocale();
return (

View File

@ -1,3 +1,5 @@
"use client";
import { useReducer } from "react";
import { z } from "zod";

View File

@ -5,7 +5,6 @@ import type { GetServerSidePropsContext } from "next";
import { getCsrfToken, signIn } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { CSSProperties } from "react";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { FaGoogle } from "react-icons/fa";
@ -174,15 +173,7 @@ inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
: isSAMLLoginEnabled && !isLoading && data?.connectionExists;
return (
<div
style={
{
"--cal-brand": "#111827",
"--cal-brand-emphasis": "#101010",
"--cal-brand-text": "white",
"--cal-brand-subtle": "#9CA3AF",
} as CSSProperties
}>
<div className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand-emphasis:#101010] [--cal-brand-subtle:9CA3AF] [--cal-brand-text:white] [--cal-brand:#111827] dark:[--cal-brand-emphasis:#e1e1e1] dark:[--cal-brand-text:black] dark:[--cal-brand:white]">
<AuthContainer
title={t("login")}
description={t("login")}
@ -238,7 +229,7 @@ inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
type="submit"
color="primary"
disabled={formState.isSubmitting}
className="w-full justify-center dark:bg-white dark:text-black">
className="w-full justify-center">
{twoFactorRequired ? t("submit") : t("sign_in")}
</Button>
</div>

View File

@ -1,12 +1,13 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { GetStaticPaths, GetStaticProps } from "next";
import { Fragment } from "react";
import { Fragment, useState } from "react";
import React from "react";
import { z } from "zod";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
import dayjs from "@calcom/dayjs";
import { getLayout } from "@calcom/features/MainLayout";
import { FilterToggle } from "@calcom/features/bookings/components/FilterToggle";
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
@ -81,6 +82,7 @@ export default function Bookings() {
const { status } = params ? querySchema.parse(params) : { status: "upcoming" as const };
const { t } = useLocale();
const user = useMeQuery().data;
const [isFiltersVisible, setIsFiltersVisible] = useState<boolean>(false);
const query = trpc.viewer.bookings.get.useInfiniteQuery(
{
@ -151,12 +153,11 @@ export default function Bookings() {
return (
<ShellMain hideHeadingOnMobile heading={t("bookings")} subtitle={t("bookings_description")}>
<div className="flex flex-col">
<div className="flex flex-col flex-wrap lg:flex-row">
<div className="flex flex-row flex-wrap justify-between">
<HorizontalTabs tabs={tabs} />
<div className="max-w-full overflow-x-auto xl:ml-auto">
<FiltersContainer />
</div>
<FilterToggle setIsFiltersVisible={setIsFiltersVisible} />
</div>
<FiltersContainer isFiltersVisible={isFiltersVisible} />
<main className="w-full">
<div className="flex w-full flex-col" ref={animationParentRef}>
{query.status === "error" && (

View File

@ -2,7 +2,6 @@ import type { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import Head from "next/head";
import { usePathname, useRouter } from "next/navigation";
import type { CSSProperties } from "react";
import { Suspense } from "react";
import { z } from "zod";
@ -106,16 +105,8 @@ const OnboardingPage = () => {
return (
<div
className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen"
className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand-emphasis:#101010] [--cal-brand-subtle:9CA3AF] [--cal-brand:#111827] [--cal-brand-text:#FFFFFF] dark:[--cal-brand-emphasis:#e1e1e1] dark:[--cal-brand:white] dark:[--cal-brand-text:#000000]"
data-testid="onboarding"
style={
{
"--cal-brand": "#111827",
"--cal-brand-emphasis": "#101010",
"--cal-brand-text": "white",
"--cal-brand-subtle": "#9CA3AF",
} as CSSProperties
}
key={pathname}>
<Head>
<title>{`${APP_NAME} - ${t("getting_started")}`}</title>
@ -231,7 +222,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
};
};
OnboardingPage.isThemeSupported = false;
OnboardingPage.PageWrapper = PageWrapper;
export default OnboardingPage;

View File

@ -1,6 +1,8 @@
import type { GetServerSidePropsContext } from "next";
import z from "zod";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import PageWrapper from "@components/PageWrapper";
@ -10,21 +12,29 @@ import UserTypePage, { getServerSideProps as GSSUserTypePage } from "../../../[u
import type { PageProps as TeamTypePageProps } from "../../../team/[slug]/[type]";
import TeamTypePage, { getServerSideProps as GSSTeamTypePage } from "../../../team/[slug]/[type]";
const paramsSchema = z.object({
orgSlug: z.string().transform((s) => slugify(s)),
user: z.string().transform((s) => slugify(s)),
type: z.string().transform((s) => slugify(s)),
});
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const { user: teamOrUserSlug, orgSlug, type } = paramsSchema.parse(ctx.params);
const team = await prisma.team.findFirst({
where: {
slug: ctx.query.user as string,
slug: teamOrUserSlug,
parentId: {
not: null,
},
parent: getSlugOrRequestedSlug(ctx.query.orgSlug as string),
parent: getSlugOrRequestedSlug(orgSlug),
},
select: {
id: true,
},
});
if (team) {
const params = { slug: ctx.query.user, type: ctx.query.type };
const params = { slug: teamOrUserSlug, type };
return GSSTeamTypePage({
...ctx,
params: {
@ -37,7 +47,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
},
});
}
const params = { user: ctx.query.user, type: ctx.query.type };
const params = { user: teamOrUserSlug, type };
return GSSUserTypePage({
...ctx,
params: {

View File

@ -1,5 +1,3 @@
"use client";
import AdminOrgsPage from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgPage";
import type { CalPageWrapper } from "@components/PageWrapper";

View File

@ -1,5 +1,3 @@
"use client";
import UsersEditView from "@calcom/features/ee/users/pages/users-edit-view";
import type { CalPageWrapper } from "@components/PageWrapper";

View File

@ -1,5 +1,3 @@
"use client";
import UsersAddView from "@calcom/features/ee/users/pages/users-add-view";
import type { CalPageWrapper } from "@components/PageWrapper";

View File

@ -1,5 +1,3 @@
"use client";
import UsersListingView from "@calcom/features/ee/users/pages/users-listing-view";
import type { CalPageWrapper } from "@components/PageWrapper";

View File

@ -106,8 +106,17 @@ const ProfileView = () => {
setConfirmAuthEmailChangeWarningDialogOpen(false);
setTempFormValues(null);
},
onError: () => {
showToast(t("error_updating_settings"), "error");
onError: (e) => {
switch (e.message) {
// TODO: Add error codes.
case "email_already_used":
{
showToast(t(e.message), "error");
}
return;
default:
showToast(t("error_updating_settings"), "error");
}
},
});

View File

@ -4,7 +4,6 @@ import type { GetServerSidePropsContext } from "next";
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { CSSProperties } from "react";
import { useState, useEffect } from "react";
import type { SubmitHandler } from "react-hook-form";
import { useForm, useFormContext } from "react-hook-form";
@ -238,18 +237,10 @@ export default function Signup({
};
return (
<div
className="light bg-muted 2xl:bg-default flex min-h-screen w-full flex-col items-center justify-center"
style={
{
"--cal-brand": "#111827",
"--cal-brand-emphasis": "#101010",
"--cal-brand-text": "white",
"--cal-brand-subtle": "#9CA3AF",
} as CSSProperties
}>
<div className="bg-muted 2xl:border-subtle grid w-full max-w-[1440px] grid-cols-1 grid-rows-1 lg:grid-cols-2 2xl:rounded-[20px] 2xl:border 2xl:py-6">
<div className="light bg-muted 2xl:bg-default flex min-h-screen w-full flex-col items-center justify-center [--cal-brand-emphasis:#101010] [--cal-brand:#111827] [--cal-brand-text:#FFFFFF] [--cal-brand-subtle:#9CA3AF] dark:[--cal-brand-emphasis:#e1e1e1] dark:[--cal-brand:white] dark:[--cal-brand-text:#000000]">
<div className="bg-muted 2xl:border-subtle grid w-full max-w-[1440px] grid-cols-1 grid-rows-1 overflow-hidden lg:grid-cols-2 2xl:rounded-[20px] 2xl:border 2xl:py-6">
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
{/* Left side */}
<div className="flex w-full flex-col px-4 pt-6 sm:px-16 md:px-20 2xl:px-28">
{/* Header */}
{errors.apiError && (
@ -354,7 +345,10 @@ export default function Signup({
StartIcon={() => (
<>
<img
className={classNames("text-subtle mr-2 h-4 w-4", premiumUsername && "opacity-50")}
className={classNames(
"text-subtle mr-2 h-4 w-4 dark:invert",
premiumUsername && "opacity-50"
)}
src="/google-icon.svg"
alt=""
/>
@ -525,7 +519,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
// username + email prepopulated from query params
const { username: preFillusername, email: prefilEmail } = querySchema.parse(ctx.query);
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || flags["disable-signup"]) {
if ((process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" && !token) || flags["disable-signup"]) {
return {
notFound: true,
};
@ -648,5 +642,4 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
};
};
Signup.isThemeSupported = false;
Signup.PageWrapper = PageWrapper;

View File

@ -1,10 +1,8 @@
import type { DailyEventObjectRecordingStarted } from "@daily-co/daily-js";
import DailyIframe from "@daily-co/daily-js";
import MarkdownIt from "markdown-it";
import type { GetServerSidePropsContext } from "next";
import Head from "next/head";
import { useState, useEffect, useRef } from "react";
import z from "zod";
import dayjs from "@calcom/dayjs";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
@ -21,19 +19,12 @@ import PageWrapper from "@components/PageWrapper";
import { ssrInit } from "@server/lib/ssr";
const recordingStartedEventResponse = z
.object({
recordingId: z.string(),
})
.passthrough();
export type JoinCallPageProps = inferSSRProps<typeof getServerSideProps>;
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
export default function JoinCall(props: JoinCallPageProps) {
const { t } = useLocale();
const { meetingUrl, meetingPassword, booking } = props;
const recordingId = useRef<string | null>(null);
useEffect(() => {
const callFrame = DailyIframe.createFrame({
@ -61,31 +52,12 @@ export default function JoinCall(props: JoinCallPageProps) {
...(typeof meetingPassword === "string" && { token: meetingPassword }),
});
callFrame.join();
callFrame.on("recording-started", onRecordingStarted).on("recording-stopped", onRecordingStopped);
return () => {
callFrame.destroy();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onRecordingStopped = () => {
const data = { recordingId: recordingId.current, bookingUID: booking.uid };
fetch("/api/recorded-daily-video", {
method: "POST",
body: JSON.stringify(data),
}).catch((err) => {
console.log(err);
});
recordingId.current = null;
};
const onRecordingStarted = (event?: DailyEventObjectRecordingStarted | undefined) => {
const response = recordingStartedEventResponse.parse(event);
recordingId.current = response.recordingId;
};
const title = `${APP_NAME} Video`;
return (
<>
@ -104,15 +76,27 @@ export default function JoinCall(props: JoinCallPageProps) {
<meta property="twitter:description" content={t("quick_video_meeting")} />
</Head>
<div style={{ zIndex: 2, position: "relative" }}>
<img
className="h-5·w-auto fixed z-10 hidden sm:inline-block"
src={`${WEBSITE_URL}/cal-logo-word-dark.svg`}
alt="Cal.com Logo"
style={{
top: 46,
left: 24,
}}
/>
{booking?.user?.organization?.calVideoLogo ? (
<img
className="min-w-16 min-h-16 fixed z-10 hidden aspect-square h-16 w-16 rounded-full sm:inline-block"
src={booking.user.organization.calVideoLogo}
alt="My Org Logo"
style={{
top: 32,
left: 32,
}}
/>
) : (
<img
className="fixed z-10 hidden sm:inline-block"
src={`${WEBSITE_URL}/cal-logo-word-dark.svg`}
alt="Logo"
style={{
top: 32,
left: 32,
}}
/>
)}
</div>
<VideoMeetingInfo booking={booking} />
</>
@ -288,6 +272,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
timeZone: true,
name: true,
email: true,
organization: {
select: {
calVideoLogo: true,
},
},
},
},
references: {

View File

@ -0,0 +1,142 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.describe("apps/ A/B tests", () => {
test("should point to the /future/apps/installed/[category]", async ({ page, users, context }) => {
await context.addCookies([
{
name: "x-calcom-future-routes-override",
value: "1",
url: "http://localhost:3000",
},
]);
const user = await users.create();
await user.apiLogin();
await page.goto("/apps/installed/messaging");
await page.waitForLoadState();
const dataNextJsRouter = await page.evaluate(() =>
window.document.documentElement.getAttribute("data-nextjs-router")
);
expect(dataNextJsRouter).toEqual("app");
const locator = page.getByRole("heading", { name: "Messaging" });
await expect(locator).toBeVisible();
});
test("should point to the /future/apps/[slug]", async ({ page, users, context }) => {
await context.addCookies([
{
name: "x-calcom-future-routes-override",
value: "1",
url: "http://localhost:3000",
},
]);
const user = await users.create();
await user.apiLogin();
await page.goto("/apps/telegram");
await page.waitForLoadState();
const dataNextJsRouter = await page.evaluate(() =>
window.document.documentElement.getAttribute("data-nextjs-router")
);
expect(dataNextJsRouter).toEqual("app");
const locator = page.getByRole("heading", { name: "Telegram" });
await expect(locator).toBeVisible();
});
test("should point to the /future/apps/[slug]/setup", async ({ page, users, context }) => {
await context.addCookies([
{
name: "x-calcom-future-routes-override",
value: "1",
url: "http://localhost:3000",
},
]);
const user = await users.create();
await user.apiLogin();
await page.goto("/apps/apple-calendar/setup");
await page.waitForLoadState();
const dataNextJsRouter = await page.evaluate(() =>
window.document.documentElement.getAttribute("data-nextjs-router")
);
expect(dataNextJsRouter).toEqual("app");
const locator = page.getByRole("heading", { name: "Connect to Apple Server" });
await expect(locator).toBeVisible();
});
test("should point to the /future/apps/categories", async ({ page, users, context }) => {
await context.addCookies([
{
name: "x-calcom-future-routes-override",
value: "1",
url: "http://localhost:3000",
},
]);
const user = await users.create();
await user.apiLogin();
await page.goto("/apps/categories");
await page.waitForLoadState();
const dataNextJsRouter = await page.evaluate(() =>
window.document.documentElement.getAttribute("data-nextjs-router")
);
expect(dataNextJsRouter).toEqual("app");
const locator = page.getByTestId("app-store-category-messaging");
await expect(locator).toBeVisible();
});
test("should point to the /future/apps/categories/[category]", async ({ page, users, context }) => {
await context.addCookies([
{
name: "x-calcom-future-routes-override",
value: "1",
url: "http://localhost:3000",
},
]);
const user = await users.create();
await user.apiLogin();
await page.goto("/apps/categories/messaging");
await page.waitForLoadState();
const dataNextJsRouter = await page.evaluate(() =>
window.document.documentElement.getAttribute("data-nextjs-router")
);
expect(dataNextJsRouter).toEqual("app");
const locator = page.getByText(/messaging apps/i);
await expect(locator).toBeVisible();
});
});

View File

@ -0,0 +1,418 @@
/**
* These e2e tests only aim to cover standard cases
* Edge cases are currently handled in integration tests only
*/
import { expect } from "@playwright/test";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
import { entries } from "@calcom/prisma/zod-utils";
import type { IntervalLimit } from "@calcom/types/Calendar";
import { test } from "./lib/fixtures";
import { bookTimeSlot, createUserWithLimits } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
// used as a multiplier for duration limits
const EVENT_LENGTH = 30;
// limits used when testing each limit seperately
const BOOKING_LIMITS_SINGLE = {
PER_DAY: 2,
PER_WEEK: 2,
PER_MONTH: 2,
PER_YEAR: 2,
};
// limits used when testing multiple limits together
const BOOKING_LIMITS_MULTIPLE = {
PER_DAY: 1,
PER_WEEK: 2,
PER_MONTH: 3,
PER_YEAR: 4,
};
// prevent tests from crossing year boundaries - if currently in Oct or later, start booking in Jan instead of Nov
// (we increment months twice when checking multiple limits)
const firstDayInBookingMonth =
dayjs().month() >= 9 ? dayjs().add(1, "year").month(0).date(1) : dayjs().add(1, "month").date(1);
// avoid weekly edge cases
const firstMondayInBookingMonth = firstDayInBookingMonth.day(
firstDayInBookingMonth.date() === firstDayInBookingMonth.startOf("week").date() ? 1 : 8
);
// ensure we land on the same weekday when incrementing month
const incrementDate = (date: Dayjs, unit: dayjs.ManipulateType) => {
if (unit !== "month") return date.add(1, unit);
return date.add(1, "month").day(date.day());
};
const getLastEventUrlWithMonth = (user: Awaited<ReturnType<typeof createUserWithLimits>>, date: Dayjs) => {
return `/${user.username}/${user.eventTypes.at(-1)?.slug}?month=${date.format("YYYY-MM")}`;
};
test.describe("Booking limits", () => {
entries(BOOKING_LIMITS_SINGLE).forEach(([limitKey, bookingLimit]) => {
const limitUnit = intervalLimitKeyToUnit(limitKey);
// test one limit at a time
test(limitUnit, async ({ page, users }) => {
const slug = `booking-limit-${limitUnit}`;
const singleLimit = { [limitKey]: bookingLimit };
const user = await createUserWithLimits({
users,
slug,
length: EVENT_LENGTH,
bookingLimits: singleLimit,
});
let slotUrl = "";
const monthUrl = getLastEventUrlWithMonth(user, firstMondayInBookingMonth);
await page.goto(monthUrl);
const availableDays = page.locator('[data-testid="day"][data-disabled="false"]');
const bookingDay = availableDays.getByText(firstMondayInBookingMonth.date().toString(), {
exact: true,
});
// finish rendering days before counting
await expect(bookingDay).toBeVisible({ timeout: 10_000 });
const availableDaysBefore = await availableDays.count();
await test.step("can book up to limit", async () => {
for (let i = 0; i < bookingLimit; i++) {
await bookingDay.click();
await page.getByTestId("time").nth(0).click();
await bookTimeSlot(page);
slotUrl = page.url();
await expect(page.getByTestId("success-page")).toBeVisible();
await page.goto(monthUrl);
}
});
const expectedAvailableDays = {
day: -1,
week: -5,
month: 0,
year: 0,
};
await test.step("but not over", async () => {
// should already have navigated to monthUrl - just ensure days are rendered
await expect(page.getByTestId("day").nth(0)).toBeVisible();
// ensure the day we just booked is now blocked
await expect(bookingDay).toBeHidden({ timeout: 10_000 });
const availableDaysAfter = await availableDays.count();
// equals 0 if no available days, otherwise signed difference
expect(availableDaysAfter && availableDaysAfter - availableDaysBefore).toBe(
expectedAvailableDays[limitUnit]
);
// try to book directly via form page
await page.goto(slotUrl);
await bookTimeSlot(page);
await expect(page.getByTestId("booking-fail")).toBeVisible({ timeout: 1000 });
});
await test.step(`month after booking`, async () => {
await page.goto(getLastEventUrlWithMonth(user, firstMondayInBookingMonth.add(1, "month")));
// finish rendering days before counting
await expect(page.getByTestId("day").nth(0)).toBeVisible({ timeout: 10_000 });
// the month after we made bookings should have availability unless we hit a yearly limit
await expect((await availableDays.count()) === 0).toBe(limitUnit === "year");
});
});
});
test("multiple", async ({ page, users }) => {
const slug = "booking-limit-multiple";
const user = await createUserWithLimits({
users,
slug,
length: EVENT_LENGTH,
bookingLimits: BOOKING_LIMITS_MULTIPLE,
});
let slotUrl = "";
let bookingDate = firstMondayInBookingMonth;
// keep track of total bookings across multiple limits
let bookingCount = 0;
for (const [limitKey, limitValue] of entries(BOOKING_LIMITS_MULTIPLE)) {
const limitUnit = intervalLimitKeyToUnit(limitKey);
const monthUrl = getLastEventUrlWithMonth(user, bookingDate);
await page.goto(monthUrl);
const availableDays = page.locator('[data-testid="day"][data-disabled="false"]');
const bookingDay = availableDays.getByText(bookingDate.date().toString(), { exact: true });
// finish rendering days before counting
await expect(bookingDay).toBeVisible({ timeout: 10_000 });
const availableDaysBefore = await availableDays.count();
await test.step(`can book up ${limitUnit} to limit`, async () => {
for (let i = 0; i + bookingCount < limitValue; i++) {
await bookingDay.click();
await page.getByTestId("time").nth(0).click();
await bookTimeSlot(page);
bookingCount++;
slotUrl = page.url();
await expect(page.getByTestId("success-page")).toBeVisible();
await page.goto(monthUrl);
}
});
const expectedAvailableDays = {
day: -1,
week: -4, // one day will already be blocked by daily limit
month: 0,
year: 0,
};
await test.step("but not over", async () => {
// should already have navigated to monthUrl - just ensure days are rendered
await expect(page.getByTestId("day").nth(0)).toBeVisible();
// ensure the day we just booked is now blocked
await expect(bookingDay).toBeHidden({ timeout: 10_000 });
const availableDaysAfter = await availableDays.count();
// equals 0 if no available days, otherwise signed difference
expect(availableDaysAfter && availableDaysAfter - availableDaysBefore).toBe(
expectedAvailableDays[limitUnit]
);
// try to book directly via form page
await page.goto(slotUrl);
await bookTimeSlot(page);
await expect(page.getByTestId("booking-fail")).toBeVisible({ timeout: 1000 });
});
await test.step(`month after booking`, async () => {
await page.goto(getLastEventUrlWithMonth(user, bookingDate.add(1, "month")));
// finish rendering days before counting
await expect(page.getByTestId("day").nth(0)).toBeVisible({ timeout: 10_000 });
// the month after we made bookings should have availability unless we hit a yearly limit
await expect((await availableDays.count()) === 0).toBe(limitUnit === "year");
});
// increment date by unit after hitting each limit
bookingDate = incrementDate(bookingDate, limitUnit);
}
});
});
test.describe("Duration limits", () => {
entries(BOOKING_LIMITS_SINGLE).forEach(([limitKey, bookingLimit]) => {
const limitUnit = intervalLimitKeyToUnit(limitKey);
// test one limit at a time
test(limitUnit, async ({ page, users }) => {
const slug = `duration-limit-${limitUnit}`;
const singleLimit = { [limitKey]: bookingLimit * EVENT_LENGTH };
const user = await createUserWithLimits({
users,
slug,
length: EVENT_LENGTH,
durationLimits: singleLimit,
});
let slotUrl = "";
const monthUrl = getLastEventUrlWithMonth(user, firstMondayInBookingMonth);
await page.goto(monthUrl);
const availableDays = page.locator('[data-testid="day"][data-disabled="false"]');
const bookingDay = availableDays.getByText(firstMondayInBookingMonth.date().toString(), {
exact: true,
});
// finish rendering days before counting
await expect(bookingDay).toBeVisible({ timeout: 10_000 });
const availableDaysBefore = await availableDays.count();
await test.step("can book up to limit", async () => {
for (let i = 0; i < bookingLimit; i++) {
await bookingDay.click();
await page.getByTestId("time").nth(0).click();
await bookTimeSlot(page);
slotUrl = page.url();
await expect(page.getByTestId("success-page")).toBeVisible();
await page.goto(monthUrl);
}
});
const expectedAvailableDays = {
day: -1,
week: -5,
month: 0,
year: 0,
};
await test.step("but not over", async () => {
// should already have navigated to monthUrl - just ensure days are rendered
await expect(page.getByTestId("day").nth(0)).toBeVisible();
// ensure the day we just booked is now blocked
await expect(bookingDay).toBeHidden({ timeout: 10_000 });
const availableDaysAfter = await availableDays.count();
// equals 0 if no available days, otherwise signed difference
expect(availableDaysAfter && availableDaysAfter - availableDaysBefore).toBe(
expectedAvailableDays[limitUnit]
);
// try to book directly via form page
await page.goto(slotUrl);
await bookTimeSlot(page);
await expect(page.getByTestId("booking-fail")).toBeVisible({ timeout: 1000 });
});
await test.step(`month after booking`, async () => {
await page.goto(getLastEventUrlWithMonth(user, firstMondayInBookingMonth.add(1, "month")));
// finish rendering days before counting
await expect(page.getByTestId("day").nth(0)).toBeVisible({ timeout: 10_000 });
// the month after we made bookings should have availability unless we hit a yearly limit
await expect((await availableDays.count()) === 0).toBe(limitUnit === "year");
});
});
});
test("multiple", async ({ page, users }) => {
const slug = "duration-limit-multiple";
// multiply all booking limits by EVENT_LENGTH
const durationLimits = entries(BOOKING_LIMITS_MULTIPLE).reduce((limits, [limitKey, bookingLimit]) => {
return {
...limits,
[limitKey]: bookingLimit * EVENT_LENGTH,
};
}, {} as Record<keyof IntervalLimit, number>);
const user = await createUserWithLimits({
users,
slug,
length: EVENT_LENGTH,
durationLimits,
});
let slotUrl = "";
let bookingDate = firstMondayInBookingMonth;
// keep track of total bookings across multiple limits
let bookingCount = 0;
for (const [limitKey, limitValue] of entries(BOOKING_LIMITS_MULTIPLE)) {
const limitUnit = intervalLimitKeyToUnit(limitKey);
const monthUrl = getLastEventUrlWithMonth(user, bookingDate);
await page.goto(monthUrl);
const availableDays = page.locator('[data-testid="day"][data-disabled="false"]');
const bookingDay = availableDays.getByText(bookingDate.date().toString(), { exact: true });
// finish rendering days before counting
await expect(bookingDay).toBeVisible({ timeout: 10_000 });
const availableDaysBefore = await availableDays.count();
await test.step(`can book up ${limitUnit} to limit`, async () => {
for (let i = 0; i + bookingCount < limitValue; i++) {
await bookingDay.click();
await page.getByTestId("time").nth(0).click();
await bookTimeSlot(page);
bookingCount++;
slotUrl = page.url();
await expect(page.getByTestId("success-page")).toBeVisible();
await page.goto(monthUrl);
}
});
const expectedAvailableDays = {
day: -1,
week: -4, // one day will already be blocked by daily limit
month: 0,
year: 0,
};
await test.step("but not over", async () => {
// should already have navigated to monthUrl - just ensure days are rendered
await expect(page.getByTestId("day").nth(0)).toBeVisible();
// ensure the day we just booked is now blocked
await expect(bookingDay).toBeHidden({ timeout: 10_000 });
const availableDaysAfter = await availableDays.count();
// equals 0 if no available days, otherwise signed difference
expect(availableDaysAfter && availableDaysAfter - availableDaysBefore).toBe(
expectedAvailableDays[limitUnit]
);
// try to book directly via form page
await page.goto(slotUrl);
await bookTimeSlot(page);
await expect(page.getByTestId("booking-fail")).toBeVisible({ timeout: 1000 });
});
await test.step(`month after booking`, async () => {
await page.goto(getLastEventUrlWithMonth(user, bookingDate.add(1, "month")));
// finish rendering days before counting
await expect(page.getByTestId("day").nth(0)).toBeVisible({ timeout: 10_000 });
// the month after we made bookings should have availability unless we hit a yearly limit
await expect((await availableDays.count()) === 0).toBe(limitUnit === "year");
});
// increment date by unit after hitting each limit
bookingDate = incrementDate(bookingDate, limitUnit);
}
});
});

View File

@ -673,8 +673,8 @@ export async function login(
await passwordLocator.fill(user.password ?? user.username!);
await signInLocator.click();
// Moving away from waiting 2 seconds, as it is not a reliable way to expect session to be started
await page.waitForLoadState("networkidle");
// waiting for specific login request to resolve
await page.waitForResponse(/\/api\/auth\/callback\/credentials/);
}
export async function apiLogin(

View File

@ -11,6 +11,7 @@ import { totp } from "otplib";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
import type { IntervalLimit } from "@calcom/types/Calendar";
import type { Fixtures } from "./fixtures";
import { test } from "./fixtures";
@ -246,6 +247,38 @@ export async function expectEmailsToHaveSubject({
expect(bookerFirstEmail.subject).toBe(emailSubject);
}
export const createUserWithLimits = ({
users,
slug,
title,
length,
bookingLimits,
durationLimits,
}: {
users: Fixtures["users"];
slug: string;
title?: string;
length?: number;
bookingLimits?: IntervalLimit;
durationLimits?: IntervalLimit;
}) => {
if (!bookingLimits && !durationLimits) {
throw new Error("Need to supply at least one of bookingLimits or durationLimits");
}
return users.create({
eventTypes: [
{
slug,
title: title ?? slug,
length: length ?? 30,
bookingLimits,
durationLimits,
},
],
});
};
// this method is not used anywhere else
// but I'm keeping it here in case we need in the future
async function createUserWithSeatedEvent(users: Fixtures["users"]) {

View File

@ -11,7 +11,8 @@ test.describe("unauthorized user sees correct translations (de)", async () => {
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
// we dont need to wait for styles and images, only for dom
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=de]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
@ -35,7 +36,7 @@ test.describe("unauthorized user sees correct translations (ar)", async () => {
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=ar]").waitFor({ state: "attached" });
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
@ -59,7 +60,7 @@ test.describe("unauthorized user sees correct translations (zh)", async () => {
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=zh]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
@ -83,7 +84,7 @@ test.describe("unauthorized user sees correct translations (zh-CN)", async () =>
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=zh-CN]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
@ -107,7 +108,7 @@ test.describe("unauthorized user sees correct translations (zh-TW)", async () =>
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=zh-TW]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
@ -131,7 +132,7 @@ test.describe("unauthorized user sees correct translations (pt)", async () => {
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=pt]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
@ -155,7 +156,7 @@ test.describe("unauthorized user sees correct translations (pt-br)", async () =>
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=pt-BR]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
@ -179,7 +180,7 @@ test.describe("unauthorized user sees correct translations (es-419)", async () =
test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("load");
await page.waitForLoadState("domcontentloaded");
// es-419 is disabled in i18n config, so es should be used as fallback
await page.locator("html[lang=es]").waitFor({ state: "attached" });
@ -213,57 +214,61 @@ test.describe("authorized user sees correct translations (de)", async () => {
await test.step("should navigate to /event-types and show German translations", async () => {
await page.goto("/event-types");
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=de]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Ereignistypen", { exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1);
const locator = page.getByRole("heading", { name: "Ereignistypen", exact: true });
// locator.count() does not wait for elements
// but event-types page is client side, so it takes some time to render html
// thats why we need to use method that awaits for the element
// https://github.com/microsoft/playwright/issues/14278#issuecomment-1131754679
await expect(locator).toHaveCount(1);
}
{
const locator = page.getByText("Event Types", { exact: true });
expect(await locator.count()).toEqual(0);
await expect(locator).toHaveCount(0);
}
});
await test.step("should navigate to /bookings and show German translations", async () => {
await page.goto("/bookings");
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=de]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Buchungen", { exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1);
const locator = page.getByRole("heading", { name: "Buchungen", exact: true });
await expect(locator).toHaveCount(1);
}
{
const locator = page.getByText("Bookings", { exact: true });
expect(await locator.count()).toEqual(0);
await expect(locator).toHaveCount(0);
}
});
await test.step("should reload the /bookings and show German translations", async () => {
await page.reload();
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=de]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Buchungen", { exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1);
const locator = page.getByRole("heading", { name: "Buchungen", exact: true });
await expect(locator).toHaveCount(1);
}
{
const locator = page.getByText("Bookings", { exact: true });
expect(await locator.count()).toEqual(0);
await expect(locator).toHaveCount(0);
}
});
});
@ -285,57 +290,57 @@ test.describe("authorized user sees correct translations (pt-br)", async () => {
await test.step("should navigate to /event-types and show Brazil-Portuguese translations", async () => {
await page.goto("/event-types");
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Tipos de Eventos", { exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1);
const locator = page.getByRole("heading", { name: "Tipos de Eventos", exact: true });
await expect(locator).toHaveCount(1);
}
{
const locator = page.getByText("Event Types", { exact: true });
expect(await locator.count()).toEqual(0);
await expect(locator).toHaveCount(0);
}
});
await test.step("should navigate to /bookings and show Brazil-Portuguese translations", async () => {
await page.goto("/bookings");
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Reservas", { exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1);
const locator = page.getByRole("heading", { name: "Reservas", exact: true });
await expect(locator).toHaveCount(1);
}
{
const locator = page.getByText("Bookings", { exact: true });
expect(await locator.count()).toEqual(0);
await expect(locator).toHaveCount(0);
}
});
await test.step("should reload the /bookings and show Brazil-Portuguese translations", async () => {
await page.reload();
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Reservas", { exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1);
const locator = page.getByRole("heading", { name: "Reservas", exact: true });
await expect(locator).toHaveCount(1);
}
{
const locator = page.getByText("Bookings", { exact: true });
expect(await locator.count()).toEqual(0);
await expect(locator).toHaveCount(0);
}
});
});
@ -357,57 +362,57 @@ test.describe("authorized user sees correct translations (ar)", async () => {
await test.step("should navigate to /event-types and show Arabic translations", async () => {
await page.goto("/event-types");
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=ar]").waitFor({ state: "attached" });
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
{
const locator = page.getByText("أنواع الحدث", { exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1);
const locator = page.getByRole("heading", { name: "أنواع الحدث", exact: true });
await expect(locator).toHaveCount(1);
}
{
const locator = page.getByText("Event Types", { exact: true });
expect(await locator.count()).toEqual(0);
await expect(locator).toHaveCount(0);
}
});
await test.step("should navigate to /bookings and show Arabic translations", async () => {
await page.goto("/bookings");
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=ar]").waitFor({ state: "attached" });
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
{
const locator = page.getByText("عمليات الحجز", { exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1);
const locator = page.getByRole("heading", { name: "عمليات الحجز", exact: true });
await expect(locator).toHaveCount(1);
}
{
const locator = page.getByText("Bookings", { exact: true });
expect(await locator.count()).toEqual(0);
await expect(locator).toHaveCount(0);
}
});
await test.step("should reload the /bookings and show Arabic translations", async () => {
await page.reload();
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=ar]").waitFor({ state: "attached" });
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
{
const locator = page.getByText("عمليات الحجز", { exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1);
const locator = page.getByRole("heading", { name: "عمليات الحجز", exact: true });
await expect(locator).toHaveCount(1);
}
{
const locator = page.getByText("Bookings", { exact: true });
expect(await locator.count()).toEqual(0);
await expect(locator).toHaveCount(0);
}
});
});
@ -429,7 +434,7 @@ test.describe("authorized user sees changed translations (de->ar)", async () =>
await test.step("should change the language and show Arabic translations", async () => {
await page.goto("/settings/my-account/general");
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await page.locator(".bg-default > div > div:nth-child(2)").first().click();
await page.locator("#react-select-2-option-0").click();
@ -444,32 +449,33 @@ test.describe("authorized user sees changed translations (de->ar)", async () =>
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
{
const locator = page.getByText("عام", { exact: true }); // "general"
expect(await locator.count()).toBeGreaterThanOrEqual(1);
// at least one is visible
const locator = page.getByText("عام", { exact: true }).last(); // "general"
await expect(locator).toBeVisible();
}
{
const locator = page.getByText("Allgemein", { exact: true }); // "general"
expect(await locator.count()).toEqual(0);
await expect(locator).toHaveCount(0);
}
});
await test.step("should reload and show Arabic translations", async () => {
await page.reload();
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=ar]").waitFor({ state: "attached" });
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
{
const locator = page.getByText("عام", { exact: true }); // "general"
expect(await locator.count()).toBeGreaterThanOrEqual(1);
const locator = page.getByText("عام", { exact: true }).last(); // "general"
await expect(locator).toBeVisible();
}
{
const locator = page.getByText("Allgemein", { exact: true }); // "general"
expect(await locator.count()).toEqual(0);
await expect(locator).toHaveCount(0);
}
});
});
@ -491,7 +497,7 @@ test.describe("authorized user sees changed translations (de->pt-BR) [locale1]",
await test.step("should change the language and show Brazil-Portuguese translations", async () => {
await page.goto("/settings/my-account/general");
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await page.locator(".bg-default > div > div:nth-child(2)").first().click();
await page.locator("#react-select-2-option-14").click();
@ -506,32 +512,32 @@ test.describe("authorized user sees changed translations (de->pt-BR) [locale1]",
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Geral", { exact: true }); // "general"
expect(await locator.count()).toBeGreaterThanOrEqual(1);
const locator = page.getByText("Geral", { exact: true }).last(); // "general"
await expect(locator).toBeVisible();
}
{
const locator = page.getByText("Allgemein", { exact: true }); // "general"
expect(await locator.count()).toEqual(0);
await expect(locator).toHaveCount(0);
}
});
await test.step("should reload and show Brazil-Portuguese translations", async () => {
await page.reload();
await page.waitForLoadState("networkidle");
await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=pt-BR]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{
const locator = page.getByText("Geral", { exact: true }); // "general"
expect(await locator.count()).toBeGreaterThanOrEqual(1);
const locator = page.getByText("Geral", { exact: true }).last(); // "general"
await expect(locator).toBeVisible();
}
{
const locator = page.getByText("Allgemein", { exact: true }); // "general"
expect(await locator.count()).toEqual(0);
await expect(locator).toHaveCount(0);
}
});
});

View File

@ -54,7 +54,6 @@ test.describe("Organization", () => {
// Code verification
await expect(page.locator("#modal-title")).toBeVisible();
await page.locator("input[name='2fa1']").fill(generateTotpCode(`john@${orgDomain}.com`));
await page.locator("button:text('Verify')").click();
// Check admin email about DNS pending action
await expectInvitationEmailToBeReceived(

View File

@ -4,7 +4,8 @@ import { randomBytes } from "crypto";
import { APP_NAME, IS_PREMIUM_USERNAME_ENABLED, IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
import { test } from "./lib/fixtures";
import { getEmailsReceivedByUser } from "./lib/testUtils";
import { getEmailsReceivedByUser, localize } from "./lib/testUtils";
import { expectInvitationEmailToBeReceived } from "./team/expects";
test.describe.configure({ mode: "parallel" });
@ -12,8 +13,9 @@ test.describe("Signup Flow Test", async () => {
test.beforeEach(async ({ features }) => {
features.reset(); // This resets to the inital state not an empt yarray
});
test.afterAll(async ({ users }) => {
test.afterAll(async ({ users, emails }) => {
await users.deleteAll();
emails?.deleteAll();
});
test("Username is taken", async ({ page, users }) => {
// log in trail user
@ -228,4 +230,55 @@ test.describe("Signup Flow Test", async () => {
const verifyEmail = receivedEmails?.items[0];
expect(verifyEmail?.subject).toBe(`${APP_NAME}: Verify your account`);
});
test("If signup is disabled allow team invites", async ({ browser, page, users, emails }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== "true", "Skipping due to signup being enabled");
const t = await localize("en");
const teamOwner = await users.create(undefined, { hasTeam: true });
const { team } = await teamOwner.getFirstTeam();
await teamOwner.apiLogin();
await page.goto(`/settings/teams/${team.id}/members`);
await page.waitForLoadState("networkidle");
await test.step("Invite User to team", async () => {
// TODO: This invite logic should live in a fixture - its used in team and orgs invites (Duplicated from team/org invites)
const invitedUserEmail = `rick_${Date.now()}@domain-${Date.now()}.com`;
await page.locator(`button:text("${t("add")}")`).click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator(`button:text("${t("send_invite")}")`).click();
await page.waitForLoadState("networkidle");
const inviteLink = await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${team.name}'s admin invited you to join the team ${team.name} on Cal.com`,
"signup?token"
);
//Check newly invited member exists and is pending
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(1);
// eslint-disable-next-line playwright/no-conditional-in-test
if (!inviteLink) return;
// Follow invite link to new window
const context = await browser.newContext();
const newPage = await context.newPage();
await newPage.goto(inviteLink);
await newPage.waitForLoadState("networkidle");
const url = new URL(newPage.url());
expect(url.pathname).toBe("/signup");
// Check required fields
await newPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await newPage.locator("button[type=submit]").click();
await newPage.waitForURL("/getting-started?from=signup");
await newPage.close();
await context.close();
});
});
});

View File

@ -418,6 +418,45 @@ test.describe("Teams - Org", () => {
expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe(true);
// TODO: Assert whether the user received an email
});
test("Can access booking page with event slug and team page in lowercase/uppercase/mixedcase", async ({
page,
orgs,
users,
}) => {
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 { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
const teamSlugUpperCase = team.slug?.toUpperCase();
const teamEventSlugUpperCase = teamEventSlug.toUpperCase();
// This is the most closest to the actual user flow as org1.cal.com maps to /org/orgSlug
await page.goto(`/org/${org.slug}/${teamSlugUpperCase}/${teamEventSlugUpperCase}`);
await page.waitForSelector("[data-testid=day]");
});
});
async function doOnOrgDomain(

View File

@ -1994,7 +1994,7 @@
"select_time": "اختيار الوقت",
"select_date": "اختيار التاريخ",
"see_all_available_times": "رؤية كل الأوقات المتاحة",
"org_team_names_example": "مثال، فريق التسويق",
"org_team_names_example_1": "مثال، فريق التسويق",
"org_team_names_example_2": "مثال، فريق المبيعات",
"org_team_names_example_3": "مثال، فريق التصميم",
"org_team_names_example_4": "مثال، الفريق الهندسي",

View File

@ -1994,7 +1994,7 @@
"select_time": "Vyberte čas",
"select_date": "Vyberte datum",
"see_all_available_times": "Zobrazit všechny dostupné časy",
"org_team_names_example": "Např. marketingový tým",
"org_team_names_example_1": "Např. marketingový tým",
"org_team_names_example_2": "Např. obchodní tým",
"org_team_names_example_3": "Např. designérský tým",
"org_team_names_example_4": "Např. inženýrský tým",

View File

@ -1994,7 +1994,7 @@
"select_time": "Zeit auswählen",
"select_date": "Datum auswählen",
"see_all_available_times": "Alle verfügbaren Zeiten ansehen",
"org_team_names_example": "z.B. Marketing-Team",
"org_team_names_example_1": "z.B. Marketing-Team",
"org_team_names_example_2": "z.B. Vertriebsteam",
"org_team_names_example_3": "z. B. Design-Team",
"org_team_names_example_4": "z.B. Engineering-Team",

View File

@ -880,6 +880,7 @@
"toggle_calendars_conflict": "Toggle the calendars you want to check for conflicts to prevent double bookings.",
"connect_additional_calendar": "Connect additional calendar",
"calendar_updated_successfully": "Calendar updated successfully",
"check_here":"Check here",
"conferencing": "Conferencing",
"calendar": "Calendar",
"payments": "Payments",
@ -1637,6 +1638,7 @@
"individual": "Individual",
"all_bookings_filter_label": "All Bookings",
"all_users_filter_label": "All Users",
"all_event_types_filter_label": "All Event Types",
"your_bookings_filter_label": "Your Bookings",
"meeting_url_variable": "Meeting url",
"meeting_url_info": "The event meeting conference url",
@ -1867,6 +1869,7 @@
"review_event_type": "Review Event Type",
"looking_for_more_analytics": "Looking for more analytics?",
"looking_for_more_insights": "Looking for more Insights?",
"filters": "Filters",
"add_filter": "Add filter",
"remove_filters": "Clear all filters",
"select_user": "Select User",
@ -2024,7 +2027,7 @@
"select_time": "Select Time",
"select_date": "Select Date",
"see_all_available_times": "See all available times",
"org_team_names_example": "e.g. Marketing Team",
"org_team_names_example_1": "e.g. Marketing Team",
"org_team_names_example_2": "e.g. Sales Team",
"org_team_names_example_3": "e.g. Design Team",
"org_team_names_example_4": "e.g. Engineering Team",
@ -2040,6 +2043,9 @@
"description_requires_booker_email_verification": "To ensure booker's email verification before scheduling events",
"requires_confirmation_mandatory": "Text messages can only be sent to attendees when event type requires confirmation.",
"organizations": "Organizations",
"upload_cal_video_logo":"Upload Cal Video Logo",
"update_cal_video_logo":"Update Cal Video Logo",
"cal_video_logo_upload_instruction":"To ensure your logo is visible against Cal video's dark background, please upload a light-colored image in PNG or SVG format to maintain transparency.",
"org_admin_other_teams": "Other teams",
"org_admin_other_teams_description": "Here you can see teams inside your organization that you are not part of. You can add yourself to them if needed.",
"no_other_teams_found": "No other teams found",
@ -2087,7 +2093,7 @@
"oAuth": "OAuth",
"recently_added":"Recently added",
"connect_all_calendars":"Connect all your calendars",
"connect_all_calendars_description":"{{appName}} reads availability from all your existing calendars.",
"connect_all_calendars_description":"{{appName}} reads availability from all your existing calendars.",
"workflow_automation":"Workflow automation",
"workflow_automation_description":"Personalise your scheduling experience with workflows",
"scheduling_for_your_team":"Workflow automation",

View File

@ -1994,7 +1994,7 @@
"select_time": "Seleccione la hora",
"select_date": "Seleccione la fecha",
"see_all_available_times": "Ver todas las horas disponibles",
"org_team_names_example": "ej. Equipo de marketing",
"org_team_names_example_1": "ej. Equipo de marketing",
"org_team_names_example_2": "ej. Equipo de ventas",
"org_team_names_example_3": "ej. Equipo de diseño",
"org_team_names_example_4": "ej. Equipo de ingeniería",

View File

@ -2000,7 +2000,7 @@
"select_time": "Sélectionner un créneau",
"select_date": "Sélectionner une date",
"see_all_available_times": "Voir tous les créneaux disponibles",
"org_team_names_example": "p. ex. Équipe marketing",
"org_team_names_example_1": "p. ex. Équipe marketing",
"org_team_names_example_2": "p. ex. Équipe de vente",
"org_team_names_example_3": "p. ex. Équipe de design",
"org_team_names_example_4": "p. ex. Équipe d'ingénierie",

View File

@ -1994,7 +1994,7 @@
"select_time": "בחירת שעה",
"select_date": "בחירת תאריך",
"see_all_available_times": "לצפייה בכל המועדים הפנויים",
"org_team_names_example": "לדוגמה, מחלקת שיווק",
"org_team_names_example_1": "לדוגמה, מחלקת שיווק",
"org_team_names_example_2": "לדוגמה, מחלקת מכירות",
"org_team_names_example_3": "לדוגמה, מחלקת עיצוב",
"org_team_names_example_4": "לדוגמה, מחלקת הנדסה",

View File

@ -1994,7 +1994,7 @@
"select_time": "Seleziona l'ora",
"select_date": "Seleziona la data",
"see_all_available_times": "Vedi tutti gli orari disponibili",
"org_team_names_example": "ad es., team di marketing",
"org_team_names_example_1": "ad es., team di marketing",
"org_team_names_example_2": "ad es., team vendite",
"org_team_names_example_3": "ad es., team di progettazione",
"org_team_names_example_4": "ad es., team di ingegneria",

View File

@ -1994,7 +1994,7 @@
"select_time": "時間帯を選ぶ",
"select_date": "日付を選ぶ",
"see_all_available_times": "出席できる時間帯をすべて表示",
"org_team_names_example": "例:マーケティングチーム",
"org_team_names_example_1": "例:マーケティングチーム",
"org_team_names_example_2": "例:営業チーム",
"org_team_names_example_3": "例:デザインチーム",
"org_team_names_example_4": "例:エンジニアリングチーム",

View File

@ -1994,7 +1994,7 @@
"select_time": "시간 선택",
"select_date": "날짜 선택",
"see_all_available_times": "모든 사용 가능한 시간 보기",
"org_team_names_example": "예: 마케팅 팀",
"org_team_names_example_1": "예: 마케팅 팀",
"org_team_names_example_2": "예: 세일즈 팀",
"org_team_names_example_3": "예: 디자인 팀",
"org_team_names_example_4": "예: 엔지니어링 팀",

View File

@ -1994,7 +1994,7 @@
"select_time": "Tijd selecteren",
"select_date": "Datum selecteren",
"see_all_available_times": "Bekijk alle beschikbare tijden",
"org_team_names_example": "bijvoorbeeld Marketingteam",
"org_team_names_example_1": "bijvoorbeeld Marketingteam",
"org_team_names_example_2": "bijvoorbeeld Verkoopteam",
"org_team_names_example_3": "bijvoorbeeld Ontwerpteam",
"org_team_names_example_4": "bijvoorbeeld Engineeringteam",

View File

@ -1994,7 +1994,7 @@
"select_time": "Wybierz godzinę",
"select_date": "Wybierz datę",
"see_all_available_times": "Zobacz wszystkie dostępne godziny",
"org_team_names_example": "np. zespół marketingowy",
"org_team_names_example_1": "np. zespół marketingowy",
"org_team_names_example_2": "np. zespół ds. sprzedaży",
"org_team_names_example_3": "np. zespół projektowy",
"org_team_names_example_4": "np. zespół ds. inżynierii",

View File

@ -1994,7 +1994,7 @@
"select_time": "Selecione o horário",
"select_date": "Selecione a data",
"see_all_available_times": "Veja todos os horários disponíveis",
"org_team_names_example": "ex.: Time de Marketing",
"org_team_names_example_1": "ex.: Time de Marketing",
"org_team_names_example_2": "ex: Time de Vendas",
"org_team_names_example_3": "ex: Time de Design",
"org_team_names_example_4": "ex: Time de Engenharia",

View File

@ -1994,7 +1994,7 @@
"select_time": "Selecionar horário",
"select_date": "Selecionar data",
"see_all_available_times": "Ver todos os horários disponíveis",
"org_team_names_example": "Por exemplo, Equipa de Marketing",
"org_team_names_example_1": "Por exemplo, Equipa de Marketing",
"org_team_names_example_2": "Por exemplo, Equipa de Vendas",
"org_team_names_example_3": "Por exemplo, Equipa de Design",
"org_team_names_example_4": "Por exemplo, Equipa de Engenharia",

View File

@ -1994,7 +1994,7 @@
"select_time": "Selectați ora",
"select_date": "Selectați data",
"see_all_available_times": "Vedeți toate orele disponibile",
"org_team_names_example": "de ex. echipa de marketing",
"org_team_names_example_1": "de ex. echipa de marketing",
"org_team_names_example_2": "de ex. echipa de vânzări",
"org_team_names_example_3": "de ex. echipa de design",
"org_team_names_example_4": "de ex. echipa de inginerie",

View File

@ -1994,7 +1994,7 @@
"select_time": "Выбрать время",
"select_date": "Выбрать дату",
"see_all_available_times": "Посмотреть все доступные интервалы времени",
"org_team_names_example": "например, команда по маркетингу",
"org_team_names_example_1": "например, команда по маркетингу",
"org_team_names_example_2": "например, Отдел продаж",
"org_team_names_example_3": "например, отдел дизайна",
"org_team_names_example_4": "например, технический отдел",

View File

@ -1994,7 +1994,7 @@
"select_time": "Izaberite vreme",
"select_date": "Izaberite datum",
"see_all_available_times": "Pogledajte sva dostupna vremena",
"org_team_names_example": "npr. Marketing tim",
"org_team_names_example_1": "npr. Marketing tim",
"org_team_names_example_2": "npr. Prodajni tim",
"org_team_names_example_3": "npr. Dizajnerski tim",
"org_team_names_example_4": "npr. Inženjerski tim",

View File

@ -1994,7 +1994,7 @@
"select_time": "Välj tid",
"select_date": "Välj datum",
"see_all_available_times": "Se alla tillgängliga tider",
"org_team_names_example": "t.ex. marknadsföringsteam",
"org_team_names_example_1": "t.ex. marknadsföringsteam",
"org_team_names_example_2": "t.ex. säljteam",
"org_team_names_example_3": "t.ex. designteam",
"org_team_names_example_4": "t.ex. ingenjörsteam",

View File

@ -1994,7 +1994,7 @@
"select_time": "Saati Seçin",
"select_date": "Tarihi Seçin",
"see_all_available_times": "Tüm müsait saatleri görün",
"org_team_names_example": "Örneğin. Pazarlama ekibi",
"org_team_names_example_1": "Örneğin. Pazarlama ekibi",
"org_team_names_example_2": "Örn. Satış Ekibi",
"org_team_names_example_3": "Örn. Tasarım Ekibi",
"org_team_names_example_4": "Örn. Mühendislik Ekibi",

View File

@ -1994,7 +1994,7 @@
"select_time": "Виберіть час",
"select_date": "Виберіть дату",
"see_all_available_times": "Переглянути доступні часові проміжки",
"org_team_names_example": "напр. команда маркетингу",
"org_team_names_example_1": "напр. команда маркетингу",
"org_team_names_example_2": "напр. відділ продажів",
"org_team_names_example_3": "напр. дизайнерський відділ",
"org_team_names_example_4": "e.g. інженерний відділ",

View File

@ -1994,7 +1994,7 @@
"select_time": "Chọn thời gian",
"select_date": "Chọn ngày",
"see_all_available_times": "Xem tất cả những thời gian trống",
"org_team_names_example": "ví dụ Nhóm Tiếp thị",
"org_team_names_example_1": "ví dụ Nhóm Tiếp thị",
"org_team_names_example_2": "ví dụ Nhóm Kinh doanh",
"org_team_names_example_3": "ví dụ Nhóm Thiết kế",
"org_team_names_example_4": "ví dụ Nhóm Kỹ thuật",

View File

@ -1995,7 +1995,7 @@
"select_time": "选择时间",
"select_date": "选择日期",
"see_all_available_times": "查看所有可预约时间",
"org_team_names_example": "例如,营销团队",
"org_team_names_example_1": "例如,营销团队",
"org_team_names_example_2": "例如,销售团队",
"org_team_names_example_3": "例如,设计团队",
"org_team_names_example_4": "例如,工程团队",

View File

@ -1994,7 +1994,7 @@
"select_time": "選取時間",
"select_date": "選取日期",
"see_all_available_times": "查看所有可預約時段",
"org_team_names_example": "例如行銷團隊",
"org_team_names_example_1": "例如行銷團隊",
"org_team_names_example_2": "例如銷售團隊",
"org_team_names_example_3": "例如設計團隊",
"org_team_names_example_4": "例如工程團隊",

View File

@ -12,6 +12,7 @@ import "vitest-fetch-mock";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook";
import { weekdayToWeekIndex, type WeekDays } from "@calcom/lib/date-fns";
import type { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
@ -19,7 +20,7 @@ import type { SchedulingType } from "@calcom/prisma/enums";
import type { BookingStatus } from "@calcom/prisma/enums";
import type { AppMeta } from "@calcom/types/App";
import type { NewCalendarEventType } from "@calcom/types/Calendar";
import type { EventBusyDate } from "@calcom/types/Calendar";
import type { EventBusyDate, IntervalLimit } from "@calcom/types/Calendar";
import { getMockPaymentService } from "./MockPaymentService";
@ -89,6 +90,7 @@ type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & {
timeZone: string;
}[];
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
weekStart?: string;
};
export type InputEventType = {
@ -110,10 +112,9 @@ export type InputEventType = {
requiresConfirmation?: boolean;
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
schedule?: InputUser["schedules"][number];
bookingLimits?: {
PER_DAY?: number;
};
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule">>;
bookingLimits?: IntervalLimit;
durationLimits?: IntervalLimit;
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule" | "bookingLimits" | "durationLimits">>;
type WhiteListedBookingProps = {
id?: number;
@ -536,20 +537,32 @@ export async function createOrganization(orgData: { name: string; slug: string }
* - `dateIncrement` adds the increment to current day
* - `monthIncrement` adds the increment to current month
* - `yearIncrement` adds the increment to current year
* - `fromDate` starts incrementing from this date (default: today)
*/
export const getDate = (
param: { dateIncrement?: number; monthIncrement?: number; yearIncrement?: number } = {}
param: {
dateIncrement?: number;
monthIncrement?: number;
yearIncrement?: number;
fromDate?: Date;
} = {}
) => {
let { dateIncrement, monthIncrement, yearIncrement } = param;
let { dateIncrement, monthIncrement, yearIncrement, fromDate } = param;
dateIncrement = dateIncrement || 0;
monthIncrement = monthIncrement || 0;
yearIncrement = yearIncrement || 0;
fromDate = fromDate || new Date();
let _date = new Date().getDate() + dateIncrement;
let year = new Date().getFullYear() + yearIncrement;
fromDate.setDate(fromDate.getDate() + dateIncrement);
fromDate.setMonth(fromDate.getMonth() + monthIncrement);
fromDate.setFullYear(fromDate.getFullYear() + yearIncrement);
let _date = fromDate.getDate();
let year = fromDate.getFullYear();
// Make it start with 1 to match with DayJS requiremet
let _month = new Date().getMonth() + monthIncrement + 1;
let _month = fromDate.getMonth() + 1;
// If last day of the month(As _month is plus 1 already it is going to be the 0th day of next month which is the last day of current month)
const lastDayOfMonth = new Date(year, _month, 0).getDate();
@ -568,13 +581,35 @@ export const getDate = (
const month = _month < 10 ? `0${_month}` : _month;
return {
date,
month,
year,
date: String(date),
month: String(month),
year: String(year),
dateString: `${year}-${month}-${date}`,
};
};
const isWeekStart = (date: Date, weekStart: WeekDays) => {
return date.getDay() === weekdayToWeekIndex(weekStart);
};
export const getNextMonthNotStartingOnWeekStart = (weekStart: WeekDays, from?: Date) => {
const date = from ?? new Date();
const incrementMonth = (date: Date) => {
date.setMonth(date.getMonth() + 1);
};
// start searching from the 1st day of next month
incrementMonth(date);
date.setDate(1);
while (isWeekStart(date, weekStart)) {
incrementMonth(date);
}
return getDate({ fromDate: date });
};
export function getMockedCredential({
metadataLookupKey,
key,
@ -613,6 +648,16 @@ export function getGoogleCalendarCredential() {
});
}
export function getAppleCalendarCredential() {
return getMockedCredential({
metadataLookupKey: "applecalendar",
key: {
scope:
"https://www.applecalendar.example/auth/calendar.events https://www.applecalendar.example/auth/calendar.readonly",
},
});
}
export function getZoomAppCredential() {
return getMockedCredential({
metadataLookupKey: "zoomvideo",
@ -788,6 +833,7 @@ export function getOrganizer({
selectedCalendars,
destinationCalendar,
defaultScheduleId,
weekStart = "Sunday",
teams,
}: {
name: string;
@ -798,6 +844,7 @@ export function getOrganizer({
selectedCalendars?: InputSelectedCalendar[];
defaultScheduleId?: number | null;
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
weekStart?: WeekDays;
teams?: InputUser["teams"];
}) {
return {
@ -811,6 +858,7 @@ export function getOrganizer({
selectedCalendars,
destinationCalendar,
defaultScheduleId,
weekStart,
teams,
};
}

View File

@ -115,8 +115,9 @@
"@types/node": "16.9.1",
"@types/react": "18.0.26",
"@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"
"next-i18next@^13.2.2": "patch:next-i18next@npm%3A13.3.0#./.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch",
"libphonenumber-js@^1.10.51": "patch:libphonenumber-js@npm%3A1.10.51#./.yarn/patches/libphonenumber-js-npm-1.10.51-4ff79b15f8.patch",
"libphonenumber-js@^1.10.12": "patch:libphonenumber-js@npm%3A1.10.51#./.yarn/patches/libphonenumber-js-npm-1.10.51-4ff79b15f8.patch"
},
"lint-staged": {
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [

View File

@ -5,7 +5,7 @@ import { Toaster } from "react-hot-toast";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Button, Form, TextField } from "@calcom/ui";
import { Alert, Button, Form, PasswordField, TextField } from "@calcom/ui";
export default function AppleCalendarSetup() {
const { t } = useLocale();
@ -20,8 +20,8 @@ export default function AppleCalendarSetup() {
const [errorMessage, setErrorMessage] = useState("");
return (
<div className="bg-emphasis flex h-screen">
<div className="bg-default m-auto rounded p-5 md:w-[560px] md:p-10">
<div className="bg-emphasis flex h-screen dark:bg-inherit">
<div className="bg-default dark:bg-muted border-subtle m-auto rounded p-5 dark:border md:w-[560px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div>
{/* eslint-disable @next/next/no-img-element */}
@ -32,12 +32,14 @@ export default function AppleCalendarSetup() {
/>
</div>
<div>
<h1 className="text-default">{t("connect_apple_server")}</h1>
<h1 className="text-default dark:text-emphasis mb-3 font-semibold">
{t("connect_apple_server")}
</h1>
<div className="mt-1 text-sm">
{t("apple_server_generate_password", { appName: APP_NAME })}{" "}
<a
className="text-indigo-400"
className="font-bold hover:underline"
href="https://appleid.apple.com/account/manage"
target="_blank"
rel="noopener noreferrer">
@ -45,7 +47,7 @@ export default function AppleCalendarSetup() {
</a>
. {t("credentials_stored_encrypted")}
</div>
<div className="my-2 mt-3">
<div className="my-2 mt-4">
<Form
form={form}
handleSubmit={async (values) => {
@ -65,7 +67,7 @@ export default function AppleCalendarSetup() {
}
}}>
<fieldset
className="space-y-2"
className="space-y-4"
disabled={form.formState.isSubmitting}
data-testid="apple-calendar-form">
<TextField
@ -76,9 +78,8 @@ export default function AppleCalendarSetup() {
placeholder="appleid@domain.com"
data-testid="apple-calendar-email"
/>
<TextField
<PasswordField
required
type="password"
{...form.register("password")}
label={t("password")}
placeholder="•••••••••••••"

View File

@ -574,24 +574,25 @@ export default class EventManager {
(c) => c.type === destination.integration
);
// 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];
const firstCalendarCredential = destinationCalendarCredentials[0] as
| (typeof destinationCalendarCredentials)[number]
| undefined;
if (!firstCalendarCredential) {
log.warn(
"No other credentials found of the same type as the destination calendar. Falling back to first connected calendar"
);
await fallbackToFirstConnectedCalendar();
} else {
log.warn(
"No credentialId found for destination calendar, falling back to first found calendar of same type as destination calendar",
safeStringify({
destination: getPiiFreeDestinationCalendar(destination),
firstConnectedCalendar: getPiiFreeCredential(firstCalendarCredential),
})
);
createdEvents.push(await createEvent(firstCalendarCredential, event));
}
log.warn(
"No credentialId found for destination calendar, falling back to first found calendar",
safeStringify({
destination: getPiiFreeDestinationCalendar(destination),
firstConnectedCalendar: getPiiFreeCredential(firstCalendarCredential),
})
);
createdEvents.push(await createEvent(firstCalendarCredential, event));
}
}
} else {

View File

@ -7,7 +7,7 @@ import logger from "@calcom/lib/logger";
import { getPiiFreeBooking } from "@calcom/lib/piiFreeData";
import { performance } from "@calcom/lib/server/perfObserver";
import prisma from "@calcom/prisma";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type { Prisma, SelectedCalendar } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
import type { EventBusyDetails } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
@ -83,9 +83,10 @@ export async function getBusyTimes(params: {
const endTimeDate =
rescheduleUid && duration ? dayjs(endTime).add(duration, "minute").toDate() : new Date(endTime);
// startTime is less than endTimeDate and endTime grater than startTimeDate
const sharedQuery = {
startTime: { gte: startTimeDate },
endTime: { lte: endTimeDate },
startTime: { lte: endTimeDate },
endTime: { gte: startTimeDate },
status: {
in: [BookingStatus.ACCEPTED],
},
@ -264,8 +265,9 @@ export async function getBusyTimesForLimitChecks(params: {
eventTypeId: number;
startDate: Date;
endDate: Date;
rescheduleUid?: string | null;
}) {
const { userId, eventTypeId, startDate, endDate } = params;
const { userId, eventTypeId, startDate, endDate, rescheduleUid } = params;
logger.silly(
`Fetch limit checks bookings in range ${startDate} to ${endDate} for input ${JSON.stringify({
userId,
@ -275,19 +277,27 @@ export async function getBusyTimesForLimitChecks(params: {
);
performance.mark("getBusyTimesForLimitChecksStart");
const bookings = await prisma.booking.findMany({
where: {
userId,
eventTypeId,
status: BookingStatus.ACCEPTED,
// FIXME: bookings that overlap on one side will never be counted
startTime: {
gte: startDate,
},
endTime: {
lte: endDate,
},
const where: Prisma.BookingWhereInput = {
userId,
eventTypeId,
status: BookingStatus.ACCEPTED,
// FIXME: bookings that overlap on one side will never be counted
startTime: {
gte: startDate,
},
endTime: {
lte: endDate,
},
};
if (rescheduleUid) {
where.NOT = {
uid: rescheduleUid,
};
}
const bookings = await prisma.booking.findMany({
where,
select: {
id: true,
startTime: true,

View File

@ -215,7 +215,8 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
dateTo,
duration,
eventType,
user.id
user.id,
initialData?.rescheduleUid
)
: [];
@ -419,7 +420,8 @@ const _getBusyTimesFromLimits = async (
dateTo: Dayjs,
duration: number | undefined,
eventType: NonNullable<EventType>,
userId: number
userId: number,
rescheduleUid?: string | null
) => {
performance.mark("limitsStart");
@ -445,6 +447,7 @@ const _getBusyTimesFromLimits = async (
eventTypeId: eventType.id,
startDate: limitDateFrom.toDate(),
endDate: limitDateTo.toDate(),
rescheduleUid: rescheduleUid,
});
// run this first, as counting bookings should always run faster..

View File

@ -8,7 +8,7 @@
```ts
import { renderEmail } from "@calcom/emails";
renderEmail("TeamInviteEmail", {
await renderEmail("TeamInviteEmail", {
language: t,
from: "teampro@example.com",
to: "pro@example.com",

View File

@ -13,7 +13,7 @@
"@calcom/embed-snippet": ["../embed-snippet/src"]
}
},
"include": ["**/*.ts", "**/*.tsx", "env.d.ts"],
"include": ["src/**/*.ts", "src/**/*.tsx", "env.d.ts"],
// Exclude "test" because that has `api.test.ts` which imports @calcom/embed-react which needs it to be built using this tsconfig.json first. Excluding it here prevents type-check from validating test folder
"exclude": ["node_modules", "test"]
}

Some files were not shown because too many files have changed in this diff Show More