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_API_KEY=
DAILY_SCALE_PLAN='' DAILY_SCALE_PLAN=''
DAILY_WEBHOOK_SECRET=''
# - GOOGLE CALENDAR/MEET/LOGIN # - GOOGLE CALENDAR/MEET/LOGIN
# Needed to enable Google Calendar integration and Login with Google # 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` # Make sure to run mailhog container manually or with `yarn dx`
E2E_TEST_MAILHOG_ENABLED= 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 # 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 # whether we redirect to the future/event-types from event-types or not
APP_ROUTER_EVENT_TYPES_ENABLED=1 APP_ROUTER_EVENT_TYPES_ENABLED=1
APP_ROUTER_SETTINGS_ADMIN_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 # api v2
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/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) - [ ] Chore (refactoring code, technical debt, workflow improvements)
- [ ] New feature (non-breaking change which adds functionality) - [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] 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 - [ ] This change requires a documentation update
## How should this be tested? ## How should this be tested?

View File

@ -1,15 +1,15 @@
diff --git a/index.cjs b/index.cjs diff --git a/index.cjs b/index.cjs
index b645707a3549fc298508726e404243499bbed499..f34b0891e99b275a9218e253f303f43d31ef3f73 100644 index c83f700ae9998cd87b4c2d66ecbb2ad3d7b4603c..76a2200b57f0b9243e2c61464d578b67746ad5a4 100644
--- a/index.cjs --- a/index.cjs
+++ b/index.cjs +++ b/index.cjs
@@ -13,8 +13,8 @@ function withMetadataArgument(func, _arguments) { @@ -13,8 +13,8 @@ function withMetadataArgument(func, _arguments) {
// https://github.com/babel/babel/issues/2212#issuecomment-131827986 // https://github.com/babel/babel/issues/2212#issuecomment-131827986
// An alternative approach: // An alternative approach:
// https://www.npmjs.com/package/babel-plugin-add-module-exports // https://www.npmjs.com/package/babel-plugin-add-module-exports
-exports = module.exports = min.parsePhoneNumberFromString -exports = module.exports = min.parsePhoneNumberFromString
-exports['default'] = min.parsePhoneNumberFromString -exports['default'] = min.parsePhoneNumberFromString
+// exports = module.exports = min.parsePhoneNumberFromString +// exports = module.exports = min.parsePhoneNumberFromString
+// exports['default'] = min.parsePhoneNumberFromString +// exports['default'] = min.parsePhoneNumberFromString
// `parsePhoneNumberFromString()` named export is now considered legacy: // `parsePhoneNumberFromString()` named export is now considered legacy:
// it has been promoted to a default export due to being too verbose. // 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. 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`) 1. Set up the database using the Prisma schema (found in `packages/prisma/schema.prisma`)
In a development environment, run: 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 { withAxiom } = require("next-axiom");
const { withSentryConfig } = require("@sentry/nextjs");
module.exports = withAxiom({ const plugins = [withAxiom];
const nextConfig = {
transpilePackages: [ transpilePackages: [
"@calcom/app-store", "@calcom/app-store",
"@calcom/core", "@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) { async function authMiddleware(req: NextApiRequest) {
const { userId, prisma, isAdmin, query } = req; const { userId, prisma, isAdmin, query } = req;
if (isAdmin) {
return;
}
const { id } = schemaQueryIdParseInt.parse(query); const { id } = schemaQueryIdParseInt.parse(query);
const userWithBookings = await prisma.user.findUnique({ const userWithBookingsAndTeamIds = await prisma.user.findUnique({
where: { id: userId }, 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)) { if (!userBookingIds.includes(id)) {
throw new HttpError({ statusCode: 401, message: "You are not authorized" }); 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 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 { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server"; import { defaultResponder } from "@calcom/lib/server";
import { createContext } from "@calcom/trpc/server/createContext"; 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 { TRPCError } from "@trpc/server";
import { getHTTPStatusCodeFromError } from "@trpc/server/http"; import { getHTTPStatusCodeFromError } from "@trpc/server/http";
// Apply plugins
dayjs.extend(utc);
dayjs.extend(timezone);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
try { try {
const input = getScheduleSchema.parse(req.query); const { usernameList, ...rest } = req.query;
return await getAvailableSlots({ ctx: await createContext({ req, res }), input }); 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (cause) { } catch (cause) {
if (cause instanceof TRPCError) { if (cause instanceof TRPCError) {

View File

@ -8,7 +8,9 @@ pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
storybook-static storybook-static/*
!storybook-static/favicon.ico
!storybook-static/sb-cover.jpg
dist dist
dist-ssr dist-ssr
*.local *.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"); const config: StorybookConfig = {
module.exports = {
stories: [ stories: [
"../intro.stories.mdx", "../intro.stories.mdx",
"../../../packages/ui/components/**/*.stories.mdx", "../../../packages/ui/components/**/*.stories.mdx", // legacy SB6 stories
"../../../packages/platform/atoms/**/*.stories.mdx",
"../../../packages/features/**/*.stories.mdx",
"../../../packages/ui/components/**/*.stories.@(js|jsx|ts|tsx)", "../../../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: [ addons: [
@ -17,23 +19,23 @@ module.exports = {
getAbsolutePath("@storybook/addon-interactions"), getAbsolutePath("@storybook/addon-interactions"),
getAbsolutePath("storybook-addon-rtl-direction"), getAbsolutePath("storybook-addon-rtl-direction"),
getAbsolutePath("storybook-react-i18next"), getAbsolutePath("storybook-react-i18next"),
getAbsolutePath("@storybook/addon-mdx-gfm"),
], ],
framework: { framework: {
name: getAbsolutePath("@storybook/nextjs"), name: getAbsolutePath("@storybook/nextjs") as "@storybook/nextjs",
options: { options: {
builder: { // builder: {
fsCache: true, // fsCache: true,
lazyCompilation: true, // lazyCompilation: true,
}, // },
}, },
}, },
staticDirs: ["../public"], staticDirs: ["../public"],
webpackFinal: async (config, { configType }) => { webpackFinal: async (config, { configType }) => {
config.resolve = config.resolve || {};
config.resolve.fallback = { config.resolve.fallback = {
fs: false, fs: false,
assert: false, assert: false,
@ -61,6 +63,8 @@ module.exports = {
zlib: false, zlib: false,
}; };
config.module = config.module || {};
config.module.rules = config.module.rules || [];
config.module.rules.push({ config.module.rules.push({
test: /\.css$/, test: /\.css$/,
use: [ use: [
@ -85,6 +89,8 @@ module.exports = {
}, },
}; };
export default config;
function getAbsolutePath(value) { function getAbsolutePath(value) {
return dirname(require.resolve(join(value, "package.json"))); 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 { ArgsTable } from "@storybook/addon-docs";
import { SortType } from "@storybook/components"; import type { SortType } from "@storybook/blocks";
import { PropDescriptor } from "@storybook/store"; 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ignore storybook addon types component as any so we have to do
type Component = any; type Component = any;

View File

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

View File

@ -6,6 +6,11 @@ import z from "zod";
const ROUTES: [URLPattern, boolean][] = [ const ROUTES: [URLPattern, boolean][] = [
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const, ["/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, ["/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]) => [ ].map(([pathname, enabled]) => [
new URLPattern({ new URLPattern({
pathname, 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 { _generateMetadata } from "app/_utils";
import Page from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgPage";
export const generateMetadata = async () => export const generateMetadata = async () =>
await _generateMetadata( await _generateMetadata(
(t) => t("organizations"), (t) => t("organizations"),
(t) => t("orgs_page_description") (t) => t("orgs_page_description")
); );
export default function AppPage() { export default Page;
// @ts-expect-error FIXME Property 'Component' is incompatible with index signature
return <Page />;
}

View File

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

View File

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

View File

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

View File

@ -1,4 +1,6 @@
import { dir } from "i18next"; import { dir } from "i18next";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
import { headers, cookies } from "next/headers"; import { headers, cookies } from "next/headers";
import Script from "next/script"; import Script from "next/script";
import React from "react"; import React from "react";
@ -10,6 +12,14 @@ import { prepareRootMetadata } from "@lib/metadata";
import "../styles/globals.css"; 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 = () => export const generateMetadata = () =>
prepareRootMetadata({ prepareRootMetadata({
twitterCreator: "@calcom", twitterCreator: "@calcom",
@ -66,6 +76,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
src="https://snippet.meticulous.ai/v1/stagingMeticulousSnippet.js" 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> </head>
<body <body
className="dark:bg-darkgray-50 desktop-transparent bg-subtle antialiased" 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 DehydratedState } from "@tanstack/react-query";
import type { SSRConfig } from "next-i18next"; import type { SSRConfig } from "next-i18next";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
// import I18nLanguageHandler from "@components/I18nLanguageHandler"; // import I18nLanguageHandler from "@components/I18nLanguageHandler";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import Script from "next/script"; import Script from "next/script";
@ -20,14 +18,6 @@ export interface CalPageWrapper {
PageWrapper?: AppProps["Component"]["PageWrapper"]; 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<{ export type PageWrapperProps = Readonly<{
getLayout: ((page: React.ReactElement) => ReactNode) | null; getLayout: ((page: React.ReactElement) => ReactNode) | null;
children: React.ReactElement; children: React.ReactElement;
@ -71,13 +61,6 @@ function PageWrapper(props: PageWrapperProps) {
id="page-status" id="page-status"
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }} dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
/> />
<style jsx global>{`
:root {
--font-inter: ${interFont.style.fontFamily};
--font-cal: ${calFont.style.fontFamily};
}
`}</style>
{getLayout( {getLayout(
props.requiresLicense ? <LicenseRequired>{props.children}</LicenseRequired> : props.children props.requiresLicense ? <LicenseRequired>{props.children}</LicenseRequired> : props.children
)} )}

View File

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

View File

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

View File

@ -156,11 +156,8 @@ const UserProfile = () => {
{t("few_sentences_about_yourself")} {t("few_sentences_about_yourself")}
</p> </p>
</fieldset> </fieldset>
<Button <Button EndIcon={ArrowRight} type="submit" className="mt-8 w-full items-center justify-center">
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">
{t("finish")} {t("finish")}
<ArrowRight className="ml-2 h-4 w-4 self-center" aria-hidden="true" />
</Button> </Button>
</form> </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 { useLocale } from "@calcom/lib/hooks/useLocale";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react"; 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 { ArrowRight } from "@calcom/ui/components/icon";
import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability"; 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"> <label htmlFor="name" className="text-default mb-2 block text-sm font-medium">
{t("full_name")} {t("full_name")}
</label> </label>
<input <Input
{...register("name", { {...register("name", {
required: true, required: true,
})} })}
@ -85,7 +85,6 @@ const UserSettings = (props: IUserSettingsProps) => {
type="text" type="text"
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
className="border-default w-full rounded-md border text-sm"
/> />
{errors.name && ( {errors.name && (
<p data-testid="required" className="py-2 text-xs text-red-500"> <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" 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()} {t("current_time")} {dayjs().tz(selectedTimeZone).format("LT").toString().toLowerCase()}
</p> </p>
</div> </div>

View File

@ -97,13 +97,9 @@ const CustomI18nextProvider = (props: { children: React.ReactElement; i18n?: SSR
const clientViewerI18n = useViewerI18n(locale); const clientViewerI18n = useViewerI18n(locale);
const i18n = clientViewerI18n.data?.i18n ?? props.i18n; const i18n = clientViewerI18n.data?.i18n ?? props.i18n;
if (!i18n || !i18n._nextI18Next) {
return null;
}
return ( return (
// @ts-expect-error AppWithTranslationHoc expects AppProps // @ts-expect-error AppWithTranslationHoc expects AppProps
<AppWithTranslationHoc pageProps={{ _nextI18Next: i18n._nextI18Next }}> <AppWithTranslationHoc pageProps={{ _nextI18Next: i18n?._nextI18Next }}>
{props.children} {props.children}
</AppWithTranslationHoc> </AppWithTranslationHoc>
); );

View File

@ -64,6 +64,23 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
requestHeaders.set("x-csp-enforce", "true"); 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); requestHeaders.set("x-pathname", url.pathname);
const locale = await getLocale(req); const locale = await getLocale(req);
@ -103,6 +120,16 @@ export const config = {
"/future/event-types/", "/future/event-types/",
"/settings/admin/:path*", "/settings/admin/:path*",
"/future/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} */ /** @type {import("next").NextConfig} */
const nextConfig = { const nextConfig = {
experimental: {
serverComponentsExternalPackages: ["next-i18next"],
},
i18n: { i18n: {
...i18n, ...i18n,
localeDetection: false, localeDetection: false,
}, },
productionBrowserSourceMaps: true, productionBrowserSourceMaps: false,
/* We already do type check on GH actions */ /* We already do type check on GH actions */
typescript: { typescript: {
ignoreBuildErrors: !!process.env.CI, ignoreBuildErrors: !!process.env.CI,
@ -517,6 +520,11 @@ const nextConfig = {
destination: "/apps/installed/conferencing", destination: "/apps/installed/conferencing",
permanent: true, 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 // OAuth callbacks when sent to localhost:3000(w would be expected) should be redirected to corresponding to WEBAPP_URL
...(process.env.NODE_ENV === "development" && ...(process.env.NODE_ENV === "development" &&
// Safer to enable the redirect only when the user is opting to test out organizations // Safer to enable the redirect only when the user is opting to test out organizations

View File

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

View File

@ -1,14 +1,11 @@
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import { useSearchParams } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses"; import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
getBookingForReschedule,
getBookingForSeatedEvent,
getMultipleDurationValue,
} from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } 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 { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getUsernameList } from "@calcom/lib/defaultEvents"; import { getUsernameList } from "@calcom/lib/defaultEvents";
@ -26,6 +23,16 @@ import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps; 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({ export default function Type({
slug, slug,
user, user,
@ -35,9 +42,10 @@ export default function Type({
isBrandingHidden, isBrandingHidden,
isSEOIndexable, isSEOIndexable,
rescheduleUid, rescheduleUid,
entity, eventData,
duration,
}: PageProps) { }: PageProps) {
const searchParams = useSearchParams();
return ( return (
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}> <main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
<BookerSeo <BookerSeo
@ -46,7 +54,7 @@ export default function Type({
rescheduleUid={rescheduleUid ?? undefined} rescheduleUid={rescheduleUid ?? undefined}
hideBranding={isBrandingHidden} hideBranding={isBrandingHidden}
isSEOIndexable={isSEOIndexable ?? true} isSEOIndexable={isSEOIndexable ?? true}
entity={entity} entity={eventData.entity}
bookingData={booking} bookingData={booking}
/> />
<Booker <Booker
@ -55,8 +63,16 @@ export default function Type({
bookingData={booking} bookingData={booking}
isAway={away} isAway={away}
hideBranding={isBrandingHidden} hideBranding={isBrandingHidden}
entity={entity} entity={eventData.entity}
duration={duration} 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> </main>
); );
@ -68,7 +84,7 @@ Type.PageWrapper = PageWrapper;
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) { async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
const session = await getServerSession(context); const session = await getServerSession(context);
const { user: usernames, type: slug } = paramsSchema.parse(context.params); 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 { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context); const ssr = await ssrInit(context);
@ -120,12 +136,14 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
return { return {
props: { props: {
entity: eventData.entity, eventData: {
duration: getMultipleDurationValue( entity: eventData.entity,
eventData.metadata?.multipleDuration, length: eventData.length,
queryDuration, metadata: {
eventData.length ...eventData.metadata,
), multipleDuration: [15, 30, 60],
},
},
booking, booking,
user: usernames.join("+"), user: usernames.join("+"),
slug, slug,
@ -144,7 +162,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const session = await getServerSession(context); const session = await getServerSession(context);
const { user: usernames, type: slug } = paramsSchema.parse(context.params); const { user: usernames, type: slug } = paramsSchema.parse(context.params);
const username = usernames[0]; 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 { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const isOrgContext = currentOrgDomain && isValidOrgDomain; const isOrgContext = currentOrgDomain && isValidOrgDomain;
@ -207,15 +225,14 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
return { return {
props: { props: {
booking, booking,
duration: getMultipleDurationValue( eventData: {
eventData.metadata?.multipleDuration, entity: eventData.entity,
queryDuration, length: eventData.length,
eventData.length metadata: eventData.metadata,
), },
away: user?.away, away: user?.away,
user: username, user: username,
slug, slug,
entity: eventData.entity,
trpcState: ssr.dehydrate(), trpcState: ssr.dehydrate(),
isBrandingHidden: user?.hideBranding, isBrandingHidden: user?.hideBranding,
isSEOIndexable: user?.allowSEOIndexing, 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 { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger"; 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") { if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true") {
throw new HttpError({ throw new HttpError({
statusCode: 403, 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 // Use a try catch instead of returning res every time
try { try {
ensureReqIsPost(req); ensureReqIsPost(req);
ensureSignupIsEnabled(); ensureSignupIsEnabled(req);
/** /**
* Im not sure its worth merging these two handlers. They are different enough to be separate. * 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 type { WebhookTriggerEvents } from "@prisma/client";
import { createHmac } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod"; import { z } from "zod";
import { DailyLocationType } from "@calcom/app-store/locations"; import { DailyLocationType } from "@calcom/app-store/locations";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient"; import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient";
import { sendDailyVideoRecordingEmails } from "@calcom/emails"; import { sendDailyVideoRecordingEmails } from "@calcom/emails";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { defaultHandler } from "@calcom/lib/server"; import { defaultHandler } from "@calcom/lib/server";
import { getTranslation } from "@calcom/lib/server/i18n"; import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar"; import type { CalendarEvent } from "@calcom/types/Calendar";
const schema = z.object({ const schema = z
recordingId: z.string(), .object({
bookingUID: z.string(), 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({ const downloadLinkSchema = z.object({
download_link: z.string(), download_link: z.string(),
@ -39,8 +53,8 @@ const triggerWebhook = async ({
}; };
}) => { }) => {
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY"; 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 triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId);
const subscriberOptions = { const subscriberOptions = {
@ -62,71 +76,62 @@ const triggerWebhook = async ({
await Promise.all(promises); await Promise.all(promises);
}; };
const checkIfUserIsPartOfTheSameTeam = async ( const testRequestSchema = z.object({
teamId: number | undefined | null, test: z.enum(["test"]),
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;
};
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) { if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
return res.status(405).json({ message: "No SendGrid API key or 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({ return res.status(400).send({
message: "Invalid Payload", message: "Invalid Payload",
}); });
} }
const { recordingId, bookingUID } = response.data; const { room_name, recording_id, status } = response.data.payload;
const session = await getServerSession({ req, res });
if (!session?.user) { if (status !== "finished") {
return res.status(401).send({ return res.status(400).send({
message: "User not logged in", message: "Recording not finished",
}); });
} }
try { 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: { where: {
uid: bookingUID, id: bookingReference.bookingId,
}, },
select: { select: {
...bookingMinimalSelect, ...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({ 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 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({ await prisma.booking.update({
where: { where: {
uid: booking.uid, 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 downloadLinkResponse = downloadLinkSchema.parse(response);
const downloadLink = downloadLinkResponse.download_link; 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 // send emails to all attendees only when user has team plan
if (isSendingEmailsAllowed) { await sendDailyVideoRecordingEmails(evt, downloadLink);
await sendDailyVideoRecordingEmails(evt, downloadLink); return res.status(200).json({ message: "Success" });
return res.status(200).json({ message: "Success" });
}
return res.status(403).json({ message: "User does not have team plan to send out emails" });
} catch (err) { } 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" }); return res.status(500).json({ message: "something went wrong" });
} }
} }

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
"use client";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import Link from "next/link"; import Link from "next/link";
@ -13,7 +15,7 @@ import PageWrapper from "@components/PageWrapper";
import { ssrInit } from "@server/lib/ssr"; 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(); const { t, isLocaleReady } = useLocale();
return ( return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,3 @@
"use client";
import AdminOrgsPage from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgPage"; import AdminOrgsPage from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgPage";
import type { CalPageWrapper } from "@components/PageWrapper"; 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 UsersEditView from "@calcom/features/ee/users/pages/users-edit-view";
import type { CalPageWrapper } from "@components/PageWrapper"; 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 UsersAddView from "@calcom/features/ee/users/pages/users-add-view";
import type { CalPageWrapper } from "@components/PageWrapper"; 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 UsersListingView from "@calcom/features/ee/users/pages/users-listing-view";
import type { CalPageWrapper } from "@components/PageWrapper"; import type { CalPageWrapper } from "@components/PageWrapper";

View File

@ -106,8 +106,17 @@ const ProfileView = () => {
setConfirmAuthEmailChangeWarningDialogOpen(false); setConfirmAuthEmailChangeWarningDialogOpen(false);
setTempFormValues(null); setTempFormValues(null);
}, },
onError: () => { onError: (e) => {
showToast(t("error_updating_settings"), "error"); 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 { signIn } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { CSSProperties } from "react";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import type { SubmitHandler } from "react-hook-form"; import type { SubmitHandler } from "react-hook-form";
import { useForm, useFormContext } from "react-hook-form"; import { useForm, useFormContext } from "react-hook-form";
@ -238,18 +237,10 @@ export default function Signup({
}; };
return ( return (
<div <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]">
className="light bg-muted 2xl:bg-default flex min-h-screen w-full flex-col items-center justify-center" <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">
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">
<HeadSeo title={t("sign_up")} description={t("sign_up")} /> <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"> <div className="flex w-full flex-col px-4 pt-6 sm:px-16 md:px-20 2xl:px-28">
{/* Header */} {/* Header */}
{errors.apiError && ( {errors.apiError && (
@ -354,7 +345,10 @@ export default function Signup({
StartIcon={() => ( StartIcon={() => (
<> <>
<img <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" src="/google-icon.svg"
alt="" alt=""
/> />
@ -525,7 +519,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
// username + email prepopulated from query params // username + email prepopulated from query params
const { username: preFillusername, email: prefilEmail } = querySchema.parse(ctx.query); 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 { return {
notFound: true, notFound: true,
}; };
@ -648,5 +642,4 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
}; };
}; };
Signup.isThemeSupported = false;
Signup.PageWrapper = PageWrapper; 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 DailyIframe from "@daily-co/daily-js";
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import Head from "next/head"; import Head from "next/head";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import z from "zod";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
@ -21,19 +19,12 @@ import PageWrapper from "@components/PageWrapper";
import { ssrInit } from "@server/lib/ssr"; import { ssrInit } from "@server/lib/ssr";
const recordingStartedEventResponse = z
.object({
recordingId: z.string(),
})
.passthrough();
export type JoinCallPageProps = inferSSRProps<typeof getServerSideProps>; export type JoinCallPageProps = inferSSRProps<typeof getServerSideProps>;
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true }); const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
export default function JoinCall(props: JoinCallPageProps) { export default function JoinCall(props: JoinCallPageProps) {
const { t } = useLocale(); const { t } = useLocale();
const { meetingUrl, meetingPassword, booking } = props; const { meetingUrl, meetingPassword, booking } = props;
const recordingId = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
const callFrame = DailyIframe.createFrame({ const callFrame = DailyIframe.createFrame({
@ -61,31 +52,12 @@ export default function JoinCall(props: JoinCallPageProps) {
...(typeof meetingPassword === "string" && { token: meetingPassword }), ...(typeof meetingPassword === "string" && { token: meetingPassword }),
}); });
callFrame.join(); callFrame.join();
callFrame.on("recording-started", onRecordingStarted).on("recording-stopped", onRecordingStopped);
return () => { return () => {
callFrame.destroy(); callFrame.destroy();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // 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`; const title = `${APP_NAME} Video`;
return ( return (
<> <>
@ -104,15 +76,27 @@ export default function JoinCall(props: JoinCallPageProps) {
<meta property="twitter:description" content={t("quick_video_meeting")} /> <meta property="twitter:description" content={t("quick_video_meeting")} />
</Head> </Head>
<div style={{ zIndex: 2, position: "relative" }}> <div style={{ zIndex: 2, position: "relative" }}>
<img {booking?.user?.organization?.calVideoLogo ? (
className="h-5·w-auto fixed z-10 hidden sm:inline-block" <img
src={`${WEBSITE_URL}/cal-logo-word-dark.svg`} className="min-w-16 min-h-16 fixed z-10 hidden aspect-square h-16 w-16 rounded-full sm:inline-block"
alt="Cal.com Logo" src={booking.user.organization.calVideoLogo}
style={{ alt="My Org Logo"
top: 46, style={{
left: 24, 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> </div>
<VideoMeetingInfo booking={booking} /> <VideoMeetingInfo booking={booking} />
</> </>
@ -288,6 +272,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
timeZone: true, timeZone: true,
name: true, name: true,
email: true, email: true,
organization: {
select: {
calVideoLogo: true,
},
},
}, },
}, },
references: { 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 passwordLocator.fill(user.password ?? user.username!);
await signInLocator.click(); await signInLocator.click();
// Moving away from waiting 2 seconds, as it is not a reliable way to expect session to be started // waiting for specific login request to resolve
await page.waitForLoadState("networkidle"); await page.waitForResponse(/\/api\/auth\/callback\/credentials/);
} }
export async function apiLogin( export async function apiLogin(

View File

@ -11,6 +11,7 @@ import { totp } from "otplib";
import type { Prisma } from "@calcom/prisma/client"; import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums";
import type { IntervalLimit } from "@calcom/types/Calendar";
import type { Fixtures } from "./fixtures"; import type { Fixtures } from "./fixtures";
import { test } from "./fixtures"; import { test } from "./fixtures";
@ -246,6 +247,38 @@ export async function expectEmailsToHaveSubject({
expect(bookerFirstEmail.subject).toBe(emailSubject); 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 // this method is not used anywhere else
// but I'm keeping it here in case we need in the future // but I'm keeping it here in case we need in the future
async function createUserWithSeatedEvent(users: Fixtures["users"]) { 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 }) => { test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/"); 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[lang=de]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").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 }) => { test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await page.waitForLoadState("load"); await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=ar]").waitFor({ state: "attached" }); await page.locator("html[lang=ar]").waitFor({ state: "attached" });
await page.locator("html[dir=rtl]").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 }) => { test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await page.waitForLoadState("load"); await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=zh]").waitFor({ state: "attached" }); await page.locator("html[lang=zh]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").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 }) => { test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/"); 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[lang=zh-CN]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").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 }) => { test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/"); 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[lang=zh-TW]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").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 }) => { test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await page.waitForLoadState("load"); await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=pt]").waitFor({ state: "attached" }); await page.locator("html[lang=pt]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").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 }) => { test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/"); 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[lang=pt-BR]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").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 }) => { test("should use correct translations and html attributes", async ({ page }) => {
await page.goto("/"); 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 // es-419 is disabled in i18n config, so es should be used as fallback
await page.locator("html[lang=es]").waitFor({ state: "attached" }); await page.locator("html[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 test.step("should navigate to /event-types and show German translations", async () => {
await page.goto("/event-types"); 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[lang=de]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{ {
const locator = page.getByText("Ereignistypen", { exact: true }); const locator = page.getByRole("heading", { name: "Ereignistypen", exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1); // 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 }); 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 test.step("should navigate to /bookings and show German translations", async () => {
await page.goto("/bookings"); 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[lang=de]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{ {
const locator = page.getByText("Buchungen", { exact: true }); const locator = page.getByRole("heading", { name: "Buchungen", exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1); await expect(locator).toHaveCount(1);
} }
{ {
const locator = page.getByText("Bookings", { exact: true }); 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 test.step("should reload the /bookings and show German translations", async () => {
await page.reload(); await page.reload();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=de]").waitFor({ state: "attached" }); await page.locator("html[lang=de]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{ {
const locator = page.getByText("Buchungen", { exact: true }); const locator = page.getByRole("heading", { name: "Buchungen", exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1); await expect(locator).toHaveCount(1);
} }
{ {
const locator = page.getByText("Bookings", { exact: true }); 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 test.step("should navigate to /event-types and show Brazil-Portuguese translations", async () => {
await page.goto("/event-types"); 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[lang=pt-br]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{ {
const locator = page.getByText("Tipos de Eventos", { exact: true }); const locator = page.getByRole("heading", { name: "Tipos de Eventos", exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1); await expect(locator).toHaveCount(1);
} }
{ {
const locator = page.getByText("Event Types", { exact: true }); 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 test.step("should navigate to /bookings and show Brazil-Portuguese translations", async () => {
await page.goto("/bookings"); 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[lang=pt-br]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{ {
const locator = page.getByText("Reservas", { exact: true }); const locator = page.getByRole("heading", { name: "Reservas", exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1); await expect(locator).toHaveCount(1);
} }
{ {
const locator = page.getByText("Bookings", { exact: true }); 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 test.step("should reload the /bookings and show Brazil-Portuguese translations", async () => {
await page.reload(); 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[lang=pt-br]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{ {
const locator = page.getByText("Reservas", { exact: true }); const locator = page.getByRole("heading", { name: "Reservas", exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1); await expect(locator).toHaveCount(1);
} }
{ {
const locator = page.getByText("Bookings", { exact: true }); 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 test.step("should navigate to /event-types and show Arabic translations", async () => {
await page.goto("/event-types"); 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[lang=ar]").waitFor({ state: "attached" });
await page.locator("html[dir=rtl]").waitFor({ state: "attached" }); await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
{ {
const locator = page.getByText("أنواع الحدث", { exact: true }); const locator = page.getByRole("heading", { name: "أنواع الحدث", exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1); await expect(locator).toHaveCount(1);
} }
{ {
const locator = page.getByText("Event Types", { exact: true }); 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 test.step("should navigate to /bookings and show Arabic translations", async () => {
await page.goto("/bookings"); 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[lang=ar]").waitFor({ state: "attached" });
await page.locator("html[dir=rtl]").waitFor({ state: "attached" }); await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
{ {
const locator = page.getByText("عمليات الحجز", { exact: true }); const locator = page.getByRole("heading", { name: "عمليات الحجز", exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1); await expect(locator).toHaveCount(1);
} }
{ {
const locator = page.getByText("Bookings", { exact: true }); 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 test.step("should reload the /bookings and show Arabic translations", async () => {
await page.reload(); await page.reload();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=ar]").waitFor({ state: "attached" }); await page.locator("html[lang=ar]").waitFor({ state: "attached" });
await page.locator("html[dir=rtl]").waitFor({ state: "attached" }); await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
{ {
const locator = page.getByText("عمليات الحجز", { exact: true }); const locator = page.getByRole("heading", { name: "عمليات الحجز", exact: true });
expect(await locator.count()).toBeGreaterThanOrEqual(1); await expect(locator).toHaveCount(1);
} }
{ {
const locator = page.getByText("Bookings", { exact: true }); 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 test.step("should change the language and show Arabic translations", async () => {
await page.goto("/settings/my-account/general"); 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(".bg-default > div > div:nth-child(2)").first().click();
await page.locator("#react-select-2-option-0").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" }); await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
{ {
const locator = page.getByText("عام", { exact: true }); // "general" // at least one is visible
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" 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 test.step("should reload and show Arabic translations", async () => {
await page.reload(); await page.reload();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("domcontentloaded");
await page.locator("html[lang=ar]").waitFor({ state: "attached" }); await page.locator("html[lang=ar]").waitFor({ state: "attached" });
await page.locator("html[dir=rtl]").waitFor({ state: "attached" }); await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
{ {
const locator = page.getByText("عام", { exact: true }); // "general" const locator = page.getByText("عام", { exact: true }).last(); // "general"
expect(await locator.count()).toBeGreaterThanOrEqual(1); await expect(locator).toBeVisible();
} }
{ {
const locator = page.getByText("Allgemein", { exact: true }); // "general" 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 test.step("should change the language and show Brazil-Portuguese translations", async () => {
await page.goto("/settings/my-account/general"); 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(".bg-default > div > div:nth-child(2)").first().click();
await page.locator("#react-select-2-option-14").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" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{ {
const locator = page.getByText("Geral", { exact: true }); // "general" const locator = page.getByText("Geral", { exact: true }).last(); // "general"
expect(await locator.count()).toBeGreaterThanOrEqual(1); await expect(locator).toBeVisible();
} }
{ {
const locator = page.getByText("Allgemein", { exact: true }); // "general" 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 test.step("should reload and show Brazil-Portuguese translations", async () => {
await page.reload(); 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[lang=pt-BR]").waitFor({ state: "attached" });
await page.locator("html[dir=ltr]").waitFor({ state: "attached" }); await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
{ {
const locator = page.getByText("Geral", { exact: true }); // "general" const locator = page.getByText("Geral", { exact: true }).last(); // "general"
expect(await locator.count()).toBeGreaterThanOrEqual(1); await expect(locator).toBeVisible();
} }
{ {
const locator = page.getByText("Allgemein", { exact: true }); // "general" 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 // Code verification
await expect(page.locator("#modal-title")).toBeVisible(); await expect(page.locator("#modal-title")).toBeVisible();
await page.locator("input[name='2fa1']").fill(generateTotpCode(`john@${orgDomain}.com`)); 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 // Check admin email about DNS pending action
await expectInvitationEmailToBeReceived( 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 { APP_NAME, IS_PREMIUM_USERNAME_ENABLED, IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
import { test } from "./lib/fixtures"; 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" }); test.describe.configure({ mode: "parallel" });
@ -12,8 +13,9 @@ test.describe("Signup Flow Test", async () => {
test.beforeEach(async ({ features }) => { test.beforeEach(async ({ features }) => {
features.reset(); // This resets to the inital state not an empt yarray features.reset(); // This resets to the inital state not an empt yarray
}); });
test.afterAll(async ({ users }) => { test.afterAll(async ({ users, emails }) => {
await users.deleteAll(); await users.deleteAll();
emails?.deleteAll();
}); });
test("Username is taken", async ({ page, users }) => { test("Username is taken", async ({ page, users }) => {
// log in trail user // log in trail user
@ -228,4 +230,55 @@ test.describe("Signup Flow Test", async () => {
const verifyEmail = receivedEmails?.items[0]; const verifyEmail = receivedEmails?.items[0];
expect(verifyEmail?.subject).toBe(`${APP_NAME}: Verify your account`); 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); expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe(true);
// TODO: Assert whether the user received an email // 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( async function doOnOrgDomain(

View File

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

View File

@ -1994,7 +1994,7 @@
"select_time": "Vyberte čas", "select_time": "Vyberte čas",
"select_date": "Vyberte datum", "select_date": "Vyberte datum",
"see_all_available_times": "Zobrazit všechny dostupné časy", "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_2": "Např. obchodní tým",
"org_team_names_example_3": "Např. designérský tým", "org_team_names_example_3": "Např. designérský tým",
"org_team_names_example_4": "Např. inžený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_time": "Zeit auswählen",
"select_date": "Datum auswählen", "select_date": "Datum auswählen",
"see_all_available_times": "Alle verfügbaren Zeiten ansehen", "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_2": "z.B. Vertriebsteam",
"org_team_names_example_3": "z. B. Design-Team", "org_team_names_example_3": "z. B. Design-Team",
"org_team_names_example_4": "z.B. Engineering-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.", "toggle_calendars_conflict": "Toggle the calendars you want to check for conflicts to prevent double bookings.",
"connect_additional_calendar": "Connect additional calendar", "connect_additional_calendar": "Connect additional calendar",
"calendar_updated_successfully": "Calendar updated successfully", "calendar_updated_successfully": "Calendar updated successfully",
"check_here":"Check here",
"conferencing": "Conferencing", "conferencing": "Conferencing",
"calendar": "Calendar", "calendar": "Calendar",
"payments": "Payments", "payments": "Payments",
@ -1637,6 +1638,7 @@
"individual": "Individual", "individual": "Individual",
"all_bookings_filter_label": "All Bookings", "all_bookings_filter_label": "All Bookings",
"all_users_filter_label": "All Users", "all_users_filter_label": "All Users",
"all_event_types_filter_label": "All Event Types",
"your_bookings_filter_label": "Your Bookings", "your_bookings_filter_label": "Your Bookings",
"meeting_url_variable": "Meeting url", "meeting_url_variable": "Meeting url",
"meeting_url_info": "The event meeting conference url", "meeting_url_info": "The event meeting conference url",
@ -1867,6 +1869,7 @@
"review_event_type": "Review Event Type", "review_event_type": "Review Event Type",
"looking_for_more_analytics": "Looking for more analytics?", "looking_for_more_analytics": "Looking for more analytics?",
"looking_for_more_insights": "Looking for more Insights?", "looking_for_more_insights": "Looking for more Insights?",
"filters": "Filters",
"add_filter": "Add filter", "add_filter": "Add filter",
"remove_filters": "Clear all filters", "remove_filters": "Clear all filters",
"select_user": "Select User", "select_user": "Select User",
@ -2024,7 +2027,7 @@
"select_time": "Select Time", "select_time": "Select Time",
"select_date": "Select Date", "select_date": "Select Date",
"see_all_available_times": "See all available times", "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_2": "e.g. Sales Team",
"org_team_names_example_3": "e.g. Design Team", "org_team_names_example_3": "e.g. Design Team",
"org_team_names_example_4": "e.g. Engineering 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", "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.", "requires_confirmation_mandatory": "Text messages can only be sent to attendees when event type requires confirmation.",
"organizations": "Organizations", "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": "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.", "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", "no_other_teams_found": "No other teams found",
@ -2087,7 +2093,7 @@
"oAuth": "OAuth", "oAuth": "OAuth",
"recently_added":"Recently added", "recently_added":"Recently added",
"connect_all_calendars":"Connect all your calendars", "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":"Workflow automation",
"workflow_automation_description":"Personalise your scheduling experience with workflows", "workflow_automation_description":"Personalise your scheduling experience with workflows",
"scheduling_for_your_team":"Workflow automation", "scheduling_for_your_team":"Workflow automation",

View File

@ -1994,7 +1994,7 @@
"select_time": "Seleccione la hora", "select_time": "Seleccione la hora",
"select_date": "Seleccione la fecha", "select_date": "Seleccione la fecha",
"see_all_available_times": "Ver todas las horas disponibles", "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_2": "ej. Equipo de ventas",
"org_team_names_example_3": "ej. Equipo de diseño", "org_team_names_example_3": "ej. Equipo de diseño",
"org_team_names_example_4": "ej. Equipo de ingeniería", "org_team_names_example_4": "ej. Equipo de ingeniería",

View File

@ -2000,7 +2000,7 @@
"select_time": "Sélectionner un créneau", "select_time": "Sélectionner un créneau",
"select_date": "Sélectionner une date", "select_date": "Sélectionner une date",
"see_all_available_times": "Voir tous les créneaux disponibles", "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_2": "p. ex. Équipe de vente",
"org_team_names_example_3": "p. ex. Équipe de design", "org_team_names_example_3": "p. ex. Équipe de design",
"org_team_names_example_4": "p. ex. Équipe d'ingénierie", "org_team_names_example_4": "p. ex. Équipe d'ingénierie",

View File

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

View File

@ -1994,7 +1994,7 @@
"select_time": "Seleziona l'ora", "select_time": "Seleziona l'ora",
"select_date": "Seleziona la data", "select_date": "Seleziona la data",
"see_all_available_times": "Vedi tutti gli orari disponibili", "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_2": "ad es., team vendite",
"org_team_names_example_3": "ad es., team di progettazione", "org_team_names_example_3": "ad es., team di progettazione",
"org_team_names_example_4": "ad es., team di ingegneria", "org_team_names_example_4": "ad es., team di ingegneria",

View File

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

View File

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

View File

@ -1994,7 +1994,7 @@
"select_time": "Tijd selecteren", "select_time": "Tijd selecteren",
"select_date": "Datum selecteren", "select_date": "Datum selecteren",
"see_all_available_times": "Bekijk alle beschikbare tijden", "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_2": "bijvoorbeeld Verkoopteam",
"org_team_names_example_3": "bijvoorbeeld Ontwerpteam", "org_team_names_example_3": "bijvoorbeeld Ontwerpteam",
"org_team_names_example_4": "bijvoorbeeld Engineeringteam", "org_team_names_example_4": "bijvoorbeeld Engineeringteam",

View File

@ -1994,7 +1994,7 @@
"select_time": "Wybierz godzinę", "select_time": "Wybierz godzinę",
"select_date": "Wybierz datę", "select_date": "Wybierz datę",
"see_all_available_times": "Zobacz wszystkie dostępne godziny", "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_2": "np. zespół ds. sprzedaży",
"org_team_names_example_3": "np. zespół projektowy", "org_team_names_example_3": "np. zespół projektowy",
"org_team_names_example_4": "np. zespół ds. inżynierii", "org_team_names_example_4": "np. zespół ds. inżynierii",

View File

@ -1994,7 +1994,7 @@
"select_time": "Selecione o horário", "select_time": "Selecione o horário",
"select_date": "Selecione a data", "select_date": "Selecione a data",
"see_all_available_times": "Veja todos os horários disponíveis", "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_2": "ex: Time de Vendas",
"org_team_names_example_3": "ex: Time de Design", "org_team_names_example_3": "ex: Time de Design",
"org_team_names_example_4": "ex: Time de Engenharia", "org_team_names_example_4": "ex: Time de Engenharia",

View File

@ -1994,7 +1994,7 @@
"select_time": "Selecionar horário", "select_time": "Selecionar horário",
"select_date": "Selecionar data", "select_date": "Selecionar data",
"see_all_available_times": "Ver todos os horários disponíveis", "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_2": "Por exemplo, Equipa de Vendas",
"org_team_names_example_3": "Por exemplo, Equipa de Design", "org_team_names_example_3": "Por exemplo, Equipa de Design",
"org_team_names_example_4": "Por exemplo, Equipa de Engenharia", "org_team_names_example_4": "Por exemplo, Equipa de Engenharia",

View File

@ -1994,7 +1994,7 @@
"select_time": "Selectați ora", "select_time": "Selectați ora",
"select_date": "Selectați data", "select_date": "Selectați data",
"see_all_available_times": "Vedeți toate orele disponibile", "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_2": "de ex. echipa de vânzări",
"org_team_names_example_3": "de ex. echipa de design", "org_team_names_example_3": "de ex. echipa de design",
"org_team_names_example_4": "de ex. echipa de inginerie", "org_team_names_example_4": "de ex. echipa de inginerie",

View File

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

View File

@ -1994,7 +1994,7 @@
"select_time": "Izaberite vreme", "select_time": "Izaberite vreme",
"select_date": "Izaberite datum", "select_date": "Izaberite datum",
"see_all_available_times": "Pogledajte sva dostupna vremena", "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_2": "npr. Prodajni tim",
"org_team_names_example_3": "npr. Dizajnerski tim", "org_team_names_example_3": "npr. Dizajnerski tim",
"org_team_names_example_4": "npr. Inženjerski tim", "org_team_names_example_4": "npr. Inženjerski tim",

View File

@ -1994,7 +1994,7 @@
"select_time": "Välj tid", "select_time": "Välj tid",
"select_date": "Välj datum", "select_date": "Välj datum",
"see_all_available_times": "Se alla tillgängliga tider", "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_2": "t.ex. säljteam",
"org_team_names_example_3": "t.ex. designteam", "org_team_names_example_3": "t.ex. designteam",
"org_team_names_example_4": "t.ex. ingenjörsteam", "org_team_names_example_4": "t.ex. ingenjörsteam",

View File

@ -1994,7 +1994,7 @@
"select_time": "Saati Seçin", "select_time": "Saati Seçin",
"select_date": "Tarihi Seçin", "select_date": "Tarihi Seçin",
"see_all_available_times": "Tüm müsait saatleri görün", "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_2": "Örn. Satış Ekibi",
"org_team_names_example_3": "Örn. Tasarım Ekibi", "org_team_names_example_3": "Örn. Tasarım Ekibi",
"org_team_names_example_4": "Örn. Mühendislik Ekibi", "org_team_names_example_4": "Örn. Mühendislik Ekibi",

View File

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

View File

@ -1994,7 +1994,7 @@
"select_time": "Chọn thời gian", "select_time": "Chọn thời gian",
"select_date": "Chọn ngày", "select_date": "Chọn ngày",
"see_all_available_times": "Xem tất cả những thời gian trống", "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_2": "ví dụ Nhóm Kinh doanh",
"org_team_names_example_3": "ví dụ Nhóm Thiết kế", "org_team_names_example_3": "ví dụ Nhóm Thiết kế",
"org_team_names_example_4": "ví dụ Nhóm Kỹ thuật", "org_team_names_example_4": "ví dụ Nhóm Kỹ thuật",

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import "vitest-fetch-mock";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook"; 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 type { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify"; 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 { BookingStatus } from "@calcom/prisma/enums";
import type { AppMeta } from "@calcom/types/App"; import type { AppMeta } from "@calcom/types/App";
import type { NewCalendarEventType } from "@calcom/types/Calendar"; 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"; import { getMockPaymentService } from "./MockPaymentService";
@ -89,6 +90,7 @@ type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & {
timeZone: string; timeZone: string;
}[]; }[];
destinationCalendar?: Prisma.DestinationCalendarCreateInput; destinationCalendar?: Prisma.DestinationCalendarCreateInput;
weekStart?: string;
}; };
export type InputEventType = { export type InputEventType = {
@ -110,10 +112,9 @@ export type InputEventType = {
requiresConfirmation?: boolean; requiresConfirmation?: boolean;
destinationCalendar?: Prisma.DestinationCalendarCreateInput; destinationCalendar?: Prisma.DestinationCalendarCreateInput;
schedule?: InputUser["schedules"][number]; schedule?: InputUser["schedules"][number];
bookingLimits?: { bookingLimits?: IntervalLimit;
PER_DAY?: number; durationLimits?: IntervalLimit;
}; } & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule" | "bookingLimits" | "durationLimits">>;
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule">>;
type WhiteListedBookingProps = { type WhiteListedBookingProps = {
id?: number; id?: number;
@ -536,20 +537,32 @@ export async function createOrganization(orgData: { name: string; slug: string }
* - `dateIncrement` adds the increment to current day * - `dateIncrement` adds the increment to current day
* - `monthIncrement` adds the increment to current month * - `monthIncrement` adds the increment to current month
* - `yearIncrement` adds the increment to current year * - `yearIncrement` adds the increment to current year
* - `fromDate` starts incrementing from this date (default: today)
*/ */
export const getDate = ( 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; dateIncrement = dateIncrement || 0;
monthIncrement = monthIncrement || 0; monthIncrement = monthIncrement || 0;
yearIncrement = yearIncrement || 0; yearIncrement = yearIncrement || 0;
fromDate = fromDate || new Date();
let _date = new Date().getDate() + dateIncrement; fromDate.setDate(fromDate.getDate() + dateIncrement);
let year = new Date().getFullYear() + yearIncrement; 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 // 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) // 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(); const lastDayOfMonth = new Date(year, _month, 0).getDate();
@ -568,13 +581,35 @@ export const getDate = (
const month = _month < 10 ? `0${_month}` : _month; const month = _month < 10 ? `0${_month}` : _month;
return { return {
date, date: String(date),
month, month: String(month),
year, year: String(year),
dateString: `${year}-${month}-${date}`, 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({ export function getMockedCredential({
metadataLookupKey, metadataLookupKey,
key, 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() { export function getZoomAppCredential() {
return getMockedCredential({ return getMockedCredential({
metadataLookupKey: "zoomvideo", metadataLookupKey: "zoomvideo",
@ -788,6 +833,7 @@ export function getOrganizer({
selectedCalendars, selectedCalendars,
destinationCalendar, destinationCalendar,
defaultScheduleId, defaultScheduleId,
weekStart = "Sunday",
teams, teams,
}: { }: {
name: string; name: string;
@ -798,6 +844,7 @@ export function getOrganizer({
selectedCalendars?: InputSelectedCalendar[]; selectedCalendars?: InputSelectedCalendar[];
defaultScheduleId?: number | null; defaultScheduleId?: number | null;
destinationCalendar?: Prisma.DestinationCalendarCreateInput; destinationCalendar?: Prisma.DestinationCalendarCreateInput;
weekStart?: WeekDays;
teams?: InputUser["teams"]; teams?: InputUser["teams"];
}) { }) {
return { return {
@ -811,6 +858,7 @@ export function getOrganizer({
selectedCalendars, selectedCalendars,
destinationCalendar, destinationCalendar,
defaultScheduleId, defaultScheduleId,
weekStart,
teams, teams,
}; };
} }

View File

@ -115,8 +115,9 @@
"@types/node": "16.9.1", "@types/node": "16.9.1",
"@types/react": "18.0.26", "@types/react": "18.0.26",
"@types/react-dom": "^18.0.9", "@types/react-dom": "^18.0.9",
"libphonenumber-js@^1.10.12": "patch:libphonenumber-js@npm%3A1.10.12#./.yarn/patches/libphonenumber-js-npm-1.10.12-51c84f8bf1.patch", "next-i18next@^13.2.2": "patch:next-i18next@npm%3A13.3.0#./.yarn/patches/next-i18next-npm-13.3.0-bf25b0943c.patch",
"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": { "lint-staged": {
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [ "(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 { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; 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() { export default function AppleCalendarSetup() {
const { t } = useLocale(); const { t } = useLocale();
@ -20,8 +20,8 @@ export default function AppleCalendarSetup() {
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
return ( return (
<div className="bg-emphasis flex h-screen"> <div className="bg-emphasis flex h-screen dark:bg-inherit">
<div className="bg-default m-auto rounded p-5 md:w-[560px] md:p-10"> <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 className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div> <div>
{/* eslint-disable @next/next/no-img-element */} {/* eslint-disable @next/next/no-img-element */}
@ -32,12 +32,14 @@ export default function AppleCalendarSetup() {
/> />
</div> </div>
<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"> <div className="mt-1 text-sm">
{t("apple_server_generate_password", { appName: APP_NAME })}{" "} {t("apple_server_generate_password", { appName: APP_NAME })}{" "}
<a <a
className="text-indigo-400" className="font-bold hover:underline"
href="https://appleid.apple.com/account/manage" href="https://appleid.apple.com/account/manage"
target="_blank" target="_blank"
rel="noopener noreferrer"> rel="noopener noreferrer">
@ -45,7 +47,7 @@ export default function AppleCalendarSetup() {
</a> </a>
. {t("credentials_stored_encrypted")} . {t("credentials_stored_encrypted")}
</div> </div>
<div className="my-2 mt-3"> <div className="my-2 mt-4">
<Form <Form
form={form} form={form}
handleSubmit={async (values) => { handleSubmit={async (values) => {
@ -65,7 +67,7 @@ export default function AppleCalendarSetup() {
} }
}}> }}>
<fieldset <fieldset
className="space-y-2" className="space-y-4"
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}
data-testid="apple-calendar-form"> data-testid="apple-calendar-form">
<TextField <TextField
@ -76,9 +78,8 @@ export default function AppleCalendarSetup() {
placeholder="appleid@domain.com" placeholder="appleid@domain.com"
data-testid="apple-calendar-email" data-testid="apple-calendar-email"
/> />
<TextField <PasswordField
required required
type="password"
{...form.register("password")} {...form.register("password")}
label={t("password")} label={t("password")}
placeholder="•••••••••••••" placeholder="•••••••••••••"

View File

@ -574,24 +574,25 @@ export default class EventManager {
(c) => c.type === destination.integration (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. // 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) { if (!firstCalendarCredential) {
log.warn( log.warn(
"No other credentials found of the same type as the destination calendar. Falling back to first connected calendar" "No other credentials found of the same type as the destination calendar. Falling back to first connected calendar"
); );
await fallbackToFirstConnectedCalendar(); 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 { } else {

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@
"@calcom/embed-snippet": ["../embed-snippet/src"] "@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 "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"] "exclude": ["node_modules", "test"]
} }

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