Merge branch 'main' into test-datepicker

This commit is contained in:
Bailey Pumfleet 2023-08-10 12:31:47 -04:00 committed by GitHub
commit 3b0e896be6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
65 changed files with 833 additions and 536 deletions

View File

@ -40,7 +40,7 @@ export async function getHandler(req: NextApiRequest) {
// If user is not ADMIN, return only his data.
if (!isAdmin) where.id = userId;
const [total, data] = await prisma.$transaction([
prisma.user.count(),
prisma.user.count({ where }),
prisma.user.findMany({ where, take, skip }),
]);
const users = schemaUsersReadPublic.parse(data);

View File

@ -3,7 +3,7 @@ import { useRouter } from "next/navigation";
import type { ComponentProps } from "react";
import React from "react";
import Shell from "@calcom/features/shell/Shell";
import { ShellMain } from "@calcom/features/shell/Shell";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EmptyScreen } from "@calcom/ui";
import { AlertCircle } from "@calcom/ui/components/icon";
@ -12,7 +12,7 @@ type AppsLayoutProps = {
children: React.ReactNode;
actions?: (className?: string) => JSX.Element;
emptyStore?: boolean;
} & Omit<ComponentProps<typeof Shell>, "actions">;
} & Omit<ComponentProps<typeof ShellMain>, "actions">;
export default function AppsLayout({ children, actions, emptyStore, ...rest }: AppsLayoutProps) {
const { t } = useLocale();
@ -22,7 +22,7 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
if (session.status === "loading") return <></>;
return (
<Shell {...rest} actions={actions?.("block")} hideHeadingOnMobile>
<ShellMain {...rest} actions={actions?.("block")} hideHeadingOnMobile>
<div className="flex flex-col xl:flex-row">
<main className="w-full">
{emptyStore ? (
@ -38,7 +38,6 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
)}
</main>
</div>
</Shell>
</ShellMain>
);
}
export const getLayout = (page: React.ReactElement) => <AppsLayout>{page}</AppsLayout>;

View File

@ -13,6 +13,7 @@ import type { DurationType } from "@calcom/lib/convertToNewDurationType";
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
import findDurationType from "@calcom/lib/findDurationType";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ascendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
import type { PeriodType } from "@calcom/prisma/enums";
import type { IntervalLimit } from "@calcom/types/Calendar";
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
@ -462,7 +463,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
type="number"
{...offsetStartLockedProps}
label={t("offset_start")}
defaultValue={eventType.offsetStart}
{...formMethods.register("offsetStart")}
addOnSuffix={<>{t("minutes")}</>}
hint={t("offset_start_description", {
@ -477,11 +477,9 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
type IntervalLimitsKey = keyof IntervalLimit;
const intervalOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"] as const;
const INTERVAL_LIMIT_OPTIONS = intervalOrderKeys.map((key) => ({
const INTERVAL_LIMIT_OPTIONS = ascendingLimitKeys.map((key) => ({
value: key as keyof IntervalLimit,
label: `Per ${key.split("_")[1].toLocaleLowerCase()}`,
label: `Per ${intervalLimitKeyToUnit(key)}`,
}));
type IntervalLimitItemProps = {
@ -590,8 +588,8 @@ const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
Object.entries(currentIntervalLimits)
.sort(([limitKeyA], [limitKeyB]) => {
return (
intervalOrderKeys.indexOf(limitKeyA as IntervalLimitsKey) -
intervalOrderKeys.indexOf(limitKeyB as IntervalLimitsKey)
ascendingLimitKeys.indexOf(limitKeyA as IntervalLimitsKey) -
ascendingLimitKeys.indexOf(limitKeyB as IntervalLimitsKey)
);
})
.map(([key, value]) => {

View File

@ -1,3 +1,5 @@
import { Trans } from "next-i18next";
import Link from "next/link";
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useEffect, useRef } from "react";
import type { ComponentProps } from "react";
@ -99,6 +101,7 @@ const CheckedHostField = ({
isFixed,
value,
onChange,
helperText,
...rest
}: {
labelText: string;
@ -107,6 +110,7 @@ const CheckedHostField = ({
value: { isFixed: boolean; userId: number }[];
onChange?: (options: { isFixed: boolean; userId: number }[]) => void;
options?: Options<CheckedSelectOption>;
helperText?: React.ReactNode | string;
} & Omit<Partial<ComponentProps<typeof CheckedTeamSelect>>, "onChange" | "value">) => {
return (
<div className="bg-muted flex flex-col space-y-5 p-4">
@ -136,11 +140,24 @@ const CheckedHostField = ({
placeholder={placeholder}
{...rest}
/>
{helperText && <p className="text-subtle text-sm">{helperText}</p>}
</div>
</div>
);
};
const FixedHostHelper = (
<Trans i18nKey="fixed_host_helper">
Add anyone who needs to attend the event.
<Link
className="underline underline-offset-2"
target="_blank"
href="https://cal.com/docs/enterprise-features/teams/round-robin-scheduling#fixed-hosts">
Learn more
</Link>
</Trans>
);
const RoundRobinHosts = ({
teamMembers,
value,
@ -167,6 +184,7 @@ const RoundRobinHosts = ({
value={value}
placeholder={t("add_fixed_hosts")}
labelText={t("fixed_hosts")}
helperText={FixedHostHelper}
/>
<CheckedHostField
options={teamMembers.sort(sortByLabel)}
@ -175,6 +193,7 @@ const RoundRobinHosts = ({
isFixed={false}
placeholder={t("add_attendees")}
labelText={t("round_robin_hosts")}
helperText={t("round_robin_helper")}
/>
</>
);

View File

@ -2,7 +2,7 @@ import { usePathname, useSearchParams } from "next/navigation";
export default function useIsBookingPage() {
const pathname = usePathname();
const isBookingPage = ["/booking", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route));
const isBookingPage = ["/booking/", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route));
const searchParams = useSearchParams();
const userParam = searchParams.get("user");

View File

@ -127,7 +127,10 @@ const matcherConfigUserTypeEmbedRoute = {
/** @type {import("next").NextConfig} */
const nextConfig = {
i18n,
i18n: {
...i18n,
localeDetection: false,
},
productionBrowserSourceMaps: true,
/* We already do type check on GH actions */
typescript: {

View File

@ -1,7 +1,7 @@
import Head from "next/head";
import { useSearchParams } from "next/navigation";
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, showToast } from "@calcom/ui";
import { Copy } from "@calcom/ui/components/icon";
@ -21,14 +21,11 @@ export default function Error500() {
<div className="rtl: bg-default m-auto rounded-md p-10 text-right ltr:text-left">
<h1 className="font-cal text-emphasis text-6xl">500</h1>
<h2 className="text-emphasis mt-6 text-2xl font-medium">It&apos;s not you, it&apos;s us.</h2>
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">
Something went wrong on our end. Get in touch with our support team, and well get it fixed right
away for you.
</p>
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">{t("something_went_wrong_on_our_end")}</p>
{searchParams?.get("error") && (
<div className="mb-8 flex flex-col">
<p className="text-default mb-4 max-w-2xl text-sm">
Please provide the following text when contacting support to better help you:
{t("please_provide_following_text_to_suppport")}:
</p>
<pre className="bg-emphasis text-emphasis w-full max-w-2xl whitespace-normal break-words rounded-md p-4">
{searchParams?.get("error")}
@ -46,9 +43,9 @@ export default function Error500() {
</pre>
</div>
)}
<Button href={`${WEBSITE_URL}/support`}>{t("contact_support")}</Button>
<Button href="mailto:support@cal.com">{t("contact_support")}</Button>
<Button color="secondary" href="javascript:history.back()" className="ml-2">
Go back
{t("go_back")}
</Button>
</div>
</div>

View File

@ -1,3 +1,5 @@
import React from "react";
import { trpc } from "@calcom/trpc/react";
import type { AppProps } from "@lib/app-providers";

View File

@ -3,6 +3,7 @@ import type { ChangeEventHandler } from "react";
import { useState } from "react";
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
import { getLayout } from "@calcom/features/MainLayout";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import getUserAdminTeams from "@calcom/features/ee/teams/lib/getUserAdminTeams";
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
@ -94,6 +95,7 @@ export default function Apps({
}
Apps.PageWrapper = PageWrapper;
Apps.getLayout = getLayout;
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { req, res } = context;

View File

@ -1,8 +1,9 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useRouter } from "next/navigation";
import { getLayout } from "@calcom/features/MainLayout";
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
import Shell from "@calcom/features/shell/Shell";
import { ShellMain } from "@calcom/features/shell/Shell";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import type { RouterOutputs } from "@calcom/trpc/react";
@ -130,15 +131,17 @@ export default function AvailabilityPage() {
const { t } = useLocale();
return (
<div>
<Shell
<ShellMain
heading={t("availability")}
hideHeadingOnMobile
subtitle={t("configure_availability")}
CTA={<NewScheduleButton />}>
<WithQuery success={({ data }) => <AvailabilityList {...data} />} customLoader={<SkeletonLoader />} />
</Shell>
</ShellMain>
</div>
);
}
AvailabilityPage.getLayout = getLayout;
AvailabilityPage.PageWrapper = PageWrapper;

View File

@ -1,16 +1,21 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { GetStaticPaths, GetStaticProps } from "next";
import { Fragment } from "react";
import React from "react";
import { z } from "zod";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
import BookingLayout from "@calcom/features/bookings/layout/BookingLayout";
import { getLayout } from "@calcom/features/MainLayout";
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
import { ShellMain } from "@calcom/features/shell/Shell";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { HorizontalTabs } from "@calcom/ui";
import type { VerticalTabItemProps, HorizontalTabItemProps } from "@calcom/ui";
import { Alert, Button, EmptyScreen } from "@calcom/ui";
import { Calendar } from "@calcom/ui/components/icon";
@ -32,6 +37,28 @@ type RecurringInfo = {
bookings: { [key: string]: Date[] };
};
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
{
name: "upcoming",
href: "/bookings/upcoming",
},
{
name: "unconfirmed",
href: "/bookings/unconfirmed",
},
{
name: "recurring",
href: "/bookings/recurring",
},
{
name: "past",
href: "/bookings/past",
},
{
name: "cancelled",
href: "/bookings/cancelled",
},
];
const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
const descriptionByStatus: Record<NonNullable<BookingListingStatus>, string> = {
@ -112,90 +139,101 @@ export default function Bookings() {
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
return (
<BookingLayout heading={t("bookings")} subtitle={t("bookings_description")}>
<div className="flex w-full flex-col" ref={animationParentRef}>
{query.status === "error" && (
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
)}
{(query.status === "loading" || query.isPaused) && <SkeletonLoader />}
{query.status === "success" && !isEmpty && (
<>
{!!bookingsToday.length && status === "upcoming" && (
<div className="mb-6 pt-2 xl:pt-0">
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
<p className="text-subtle mb-2 text-xs font-medium uppercase leading-4">{t("today")}</p>
<div className="border-subtle overflow-hidden rounded-md border">
<table className="w-full max-w-full table-fixed">
<tbody className="bg-default divide-subtle divide-y" data-testid="today-bookings">
<Fragment>
{bookingsToday.map((booking: BookingOutput) => (
<BookingListItem
key={booking.id}
listingStatus={status}
recurringInfo={recurringInfoToday}
{...booking}
/>
<ShellMain hideHeadingOnMobile heading={t("bookings")} subtitle={t("bookings_description")}>
<div className="flex flex-col">
<div className="flex flex-col flex-wrap lg:flex-row">
<HorizontalTabs tabs={tabs} />
<div className="max-w-full overflow-x-auto xl:ml-auto">
<FiltersContainer />
</div>
</div>
<main className="w-full">
<div className="flex w-full flex-col" ref={animationParentRef}>
{query.status === "error" && (
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
)}
{(query.status === "loading" || query.isPaused) && <SkeletonLoader />}
{query.status === "success" && !isEmpty && (
<>
{!!bookingsToday.length && status === "upcoming" && (
<div className="mb-6 pt-2 xl:pt-0">
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
<p className="text-subtle mb-2 text-xs font-medium uppercase leading-4">{t("today")}</p>
<div className="border-subtle overflow-hidden rounded-md border">
<table className="w-full max-w-full table-fixed">
<tbody className="bg-default divide-subtle divide-y" data-testid="today-bookings">
<Fragment>
{bookingsToday.map((booking: BookingOutput) => (
<BookingListItem
key={booking.id}
listingStatus={status}
recurringInfo={recurringInfoToday}
{...booking}
/>
))}
</Fragment>
</tbody>
</table>
</div>
</div>
)}
<div className="pt-2 xl:pt-0">
<div className="border-subtle overflow-hidden rounded-md border">
<table className="w-full max-w-full table-fixed">
<tbody className="bg-default divide-subtle divide-y" data-testid="bookings">
{query.data.pages.map((page, index) => (
<Fragment key={index}>
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => {
const recurringInfo = page.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
);
return (
<BookingListItem
key={booking.id}
listingStatus={status}
recurringInfo={recurringInfo}
{...booking}
/>
);
})}
</Fragment>
))}
</Fragment>
</tbody>
</table>
</tbody>
</table>
</div>
<div className="text-default p-4 text-center" ref={buttonInView.ref}>
<Button
color="minimal"
loading={query.isFetchingNextPage}
disabled={!query.hasNextPage}
onClick={() => query.fetchNextPage()}>
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
</div>
</>
)}
{query.status === "success" && isEmpty && (
<div className="flex items-center justify-center pt-2 xl:pt-0">
<EmptyScreen
Icon={Calendar}
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
description={t("no_status_bookings_yet_description", {
status: t(status).toLowerCase(),
description: t(descriptionByStatus[status]),
})}
/>
</div>
)}
<div className="pt-2 xl:pt-0">
<div className="border-subtle overflow-hidden rounded-md border">
<table className="w-full max-w-full table-fixed">
<tbody className="bg-default divide-subtle divide-y" data-testid="bookings">
{query.data.pages.map((page, index) => (
<Fragment key={index}>
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => {
const recurringInfo = page.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
);
return (
<BookingListItem
key={booking.id}
listingStatus={status}
recurringInfo={recurringInfo}
{...booking}
/>
);
})}
</Fragment>
))}
</tbody>
</table>
</div>
<div className="text-default p-4 text-center" ref={buttonInView.ref}>
<Button
color="minimal"
loading={query.isFetchingNextPage}
disabled={!query.hasNextPage}
onClick={() => query.fetchNextPage()}>
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
</div>
</>
)}
{query.status === "success" && isEmpty && (
<div className="flex items-center justify-center pt-2 xl:pt-0">
<EmptyScreen
Icon={Calendar}
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
description={t("no_status_bookings_yet_description", {
status: t(status).toLowerCase(),
description: t(descriptionByStatus[status]),
})}
/>
</div>
)}
</main>
</div>
</BookingLayout>
</ShellMain>
);
}
Bookings.PageWrapper = PageWrapper;
Bookings.getLayout = getLayout;
export const getStaticProps: GetStaticProps = async (ctx) => {
const params = querySchema.safeParse(ctx.params);

View File

@ -258,6 +258,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
startDate: periodDates.startDate,
endDate: periodDates.endDate,
},
offsetStart: eventType.offsetStart,
bookingFields: eventType.bookingFields,
periodType: eventType.periodType,
periodCountCalendarDays: eventType.periodCountCalendarDays ? "1" : "0",

View File

@ -7,6 +7,7 @@ import type { FC } from "react";
import { memo, useEffect, useState } from "react";
import { z } from "zod";
import { getLayout } from "@calcom/features/MainLayout";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
@ -15,7 +16,7 @@ import CreateEventTypeDialog from "@calcom/features/eventtypes/components/Create
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import Shell from "@calcom/features/shell/Shell";
import { ShellMain } from "@calcom/features/shell/Shell";
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -932,25 +933,25 @@ const EventTypesPage = () => {
}, []);
return (
<div>
<ShellMain
withoutSeo
heading={t("event_types_page_title")}
hideHeadingOnMobile
subtitle={t("event_types_page_subtitle")}
afterHeading={showProfileBanner && <SetupProfileBanner closeAction={closeBanner} />}
beforeCTAactions={<Actions />}
CTA={<CTA data={data} />}>
<HeadSeo
title="Event Types"
description="Create events to share for people to book on your calendar."
/>
<Shell
withoutSeo
heading={t("event_types_page_title")}
hideHeadingOnMobile
subtitle={t("event_types_page_subtitle")}
afterHeading={showProfileBanner && <SetupProfileBanner closeAction={closeBanner} />}
beforeCTAactions={<Actions />}
CTA={<CTA data={data} />}>
<Main data={data} status={status} error={error} filters={filters} />
</Shell>
</div>
<Main data={data} status={status} error={error} filters={filters} />
</ShellMain>
);
};
EventTypesPage.getLayout = getLayout;
EventTypesPage.PageWrapper = PageWrapper;
export default EventTypesPage;

View File

@ -121,10 +121,10 @@ const OnboardingPage = () => {
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="mx-auto px-4 py-6 md:py-24">
<div className="mx-auto py-6 sm:px-4 md:py-24">
<div className="relative">
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
<div className="mx-auto sm:max-w-[520px]">
<div className="mx-auto px-4 sm:max-w-[520px]">
<header>
<p className="font-cal mb-3 text-[28px] font-medium leading-7">
{headers[currentStepIndex]?.title || "Undefined title"}

View File

@ -1,3 +1,4 @@
import { getLayout } from "@calcom/features/MainLayout";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import {
AverageEventDurationChart,
@ -9,7 +10,7 @@ import {
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { Filters } from "@calcom/features/insights/filters";
import Shell from "@calcom/features/shell/Shell";
import { ShellMain } from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -56,7 +57,7 @@ export default function InsightsPage() {
return (
<div>
<Shell hideHeadingOnMobile>
<ShellMain hideHeadingOnMobile>
<UpgradeTip
title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")}
@ -111,12 +112,13 @@ export default function InsightsPage() {
</FiltersProvider>
)}
</UpgradeTip>
</Shell>
</ShellMain>
</div>
);
}
InsightsPage.PageWrapper = PageWrapper;
InsightsPage.getLayout = getLayout;
// If feature flag is disabled, return not found on getServerSideProps
export const getServerSideProps = async () => {

View File

@ -272,6 +272,9 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
username = available ? username : suggestion || username;
}
// Transform all + to - in username
username = username.replace(/\+/g, "-");
return {
props: {
...props,

View File

@ -1,7 +1,8 @@
import type { GetServerSidePropsContext } from "next";
import { getLayout } from "@calcom/features/MainLayout";
import { TeamsListing } from "@calcom/features/ee/teams/components";
import Shell from "@calcom/features/shell/Shell";
import { ShellMain } from "@calcom/features/shell/Shell";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
@ -17,7 +18,7 @@ function Teams() {
const [user] = trpc.viewer.me.useSuspenseQuery();
return (
<Shell
<ShellMain
heading={t("teams")}
hideHeadingOnMobile
subtitle={t("create_manage_teams_collaborative")}
@ -33,7 +34,7 @@ function Teams() {
)
}>
<TeamsListing />
</Shell>
</ShellMain>
);
}
@ -46,5 +47,5 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
Teams.requiresLicense = false;
Teams.PageWrapper = PageWrapper;
Teams.getLayout = getLayout;
export default Teams;

View File

@ -204,7 +204,7 @@
"blog": "Blog",
"blog_description": "Lesen Sie unsere Neuigkeiten und Artikel",
"join_our_community": "Treten Sie unserer Community bei",
"join_our_discord": "Treten Sie unserem Discord-Server bei",
"join_our_discord": "Treffe uns im Discord",
"404_claim_entity_user": "Benutzername registrieren und Termine vergeben",
"popular_pages": "Beliebte Seiten",
"register_now": "Jetzt registrieren",

View File

@ -256,6 +256,8 @@
"available_apps": "Available Apps",
"available_apps_lower_case": "Available apps",
"available_apps_desc": "You have no apps installed. View popular apps below and explore more in our <1>App Store</1>",
"fixed_host_helper": "Add anyone who needs to attend the event. <1>Learn more</1>",
"round_robin_helper":"People in the group take turns and only one person will show up for the event.",
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
"finish": "Finish",
"organization_general_description": "Manage settings for your team language and timezone",
@ -1850,6 +1852,8 @@
"insights_no_data_found_for_filter": "No data found for the selected filter or selected dates.",
"acknowledge_booking_no_show_fee": "I acknowledge that if I do not attend this event that a {{amount, currency}} no show fee will be applied to my card.",
"card_details": "Card details",
"something_went_wrong_on_our_end":"Something went wrong on our end. Get in touch with our support team, and well get it fixed right away for you.",
"please_provide_following_text_to_suppport":"Please provide the following text when contacting support to better help you",
"seats_and_no_show_fee_error": "Currently cannot enable seats and charge a no-show fee",
"complete_your_booking": "Complete your booking",
"complete_your_booking_subject": "Complete your booking: {{title}} on {{date}}",

View File

@ -129,7 +129,7 @@
"team_upgrade_banner_description": "אנחנו מודים לך על כך שניסית את החבילה החדשה שלנו לצוותים. שמנו לב שהצוות שלך, \"{{teamName}}\", זקוק לשדרוג.",
"upgrade_banner_action": "כאן משדרגים",
"team_upgraded_successfully": "הצוות שלך שודרג בהצלחה!",
"org_upgrade_banner_description": "אנחנו מודים לך על כך שניסית את החבילה החדשה שלנו לארגונים. שמנו לב שהארגון שלך, \"{{teamName}}\", זקוק לשדרוג.",
"org_upgrade_banner_description": "אנחנו מודים לך על כך שניסית את החבילה החדשה שלנו לארגונים. שמנו לב שה-Organization שלך, \"{{teamName}}\", זקוק לשדרוג.",
"org_upgraded_successfully": "השדרוג של הארגון שלך בוצע בהצלחה!",
"use_link_to_reset_password": "נא להשתמש בקישור הבא כדי לאפס את הסיסמה",
"hey_there": "שלום,",
@ -551,7 +551,7 @@
"team_description": "מספר משפטים אודות הצוות. המידע הזה יופיע בדף ה-URL של הצוות.",
"org_description": "מספר משפטים אודות הארגון. הם יופיעו בדף עם כתובת ה-URL של הארגון.",
"members": "חברים",
"organization_members": "חברי הארגון",
"organization_members": "חברי Organization",
"member": "חבר/ת",
"number_member_one": "חבר {{count}}",
"number_member_other": "{{count}} חברים",
@ -699,7 +699,7 @@
"create_team_to_get_started": "צור צוות כדי להתחיל",
"teams": "צוותים",
"team": "צוות",
"organization": "ארגון",
"organization": "Organization",
"team_billing": "חיוב צוותים",
"team_billing_description": "ניהול החיוב עבור הצוות",
"upgrade_to_flexible_pro_title": "שינינו את החיוב בעבור צוותים",
@ -1151,7 +1151,7 @@
"custom": "התאמה אישית",
"reminder": "תזכורת",
"rescheduled": "נקבע מועד חדש",
"completed": "הושלם",
"completed": "הושלמה",
"reminder_email": "תזכורת: {{eventType}} עם {{name}} בתאריך {{date}}",
"not_triggering_existing_bookings": "לא יופעל עבור הזמנות קיימות מאחר שהמשתמש יתבקש למסור מספר טלפון בעת הזמנת האירוע.",
"minute_one": "{{count}} דקה",
@ -1938,7 +1938,7 @@
"insights_all_org_filter": "כל האפליקציות",
"insights_team_filter": "צוות: {{teamName}}",
"insights_user_filter": "משתמש: {{userName}}",
"insights_subtitle": "הצגת תובנות הזמנה מתוך האירועים שלך",
"insights_subtitle": "הצגת insights לגבי הזמנות מתוך האירועים שלך",
"custom_plan": "חבילה בהתאמה אישית",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -118,7 +118,7 @@
"team_info": "Informazioni sul team",
"request_another_invitation_email": "Se preferisci non usare {{toEmail}} come email {{appName}} o già hai un account {{appName}}, per favore richiedi un altro invito a quella email.",
"you_have_been_invited": "Sei stato invitato ad unirti al team {{teamName}}",
"user_invited_you": "{{user}} ti ha invitato a unirti al {{team}} {{entity}} su {{appName}}",
"user_invited_you": "{{user}} ti ha invitato a unirti a {{entity}} {{team}} su {{appName}}",
"hidden_team_member_title": "Sei nascosto in questo team",
"hidden_team_member_message": "Il tuo posto non è pagato. Passa a Pro oppure informa il proprietario del team che può pagare il tuo posto.",
"hidden_team_owner_message": "Per utilizzare i team ti occorre un account Pro. Sarai nascosto finché non cambierai piano.",
@ -129,8 +129,8 @@
"team_upgrade_banner_description": "Grazie per aver provato il nostro piano team. Abbiamo notato che è necessario effettuare l'upgrade del tuo team \"{{teamName}}\".",
"upgrade_banner_action": "Effettua l'upgrade",
"team_upgraded_successfully": "Il tuo team è stato aggiornato!",
"org_upgrade_banner_description": "Grazie per aver provato il nostro piano Organization. Abbiamo notato che è necessario eseguire l'upgrade per la tua organizzazione \"{{teamName}}\".",
"org_upgraded_successfully": "È stato eseguito l'upgrade della tua organizzazione!",
"org_upgrade_banner_description": "Grazie per aver provato il nostro piano Organization. Abbiamo notato che è necessario eseguire l'upgrade del piano Organization per \"{{teamName}}\".",
"org_upgraded_successfully": "È stato eseguito l'upgrade del piano Organization!",
"use_link_to_reset_password": "Usa il link qui sotto per reimpostare la tua password",
"hey_there": "Ciao,",
"forgot_your_password_calcom": "Hai dimenticato la password? - {{appName}}",
@ -555,7 +555,7 @@
"member": "Membri",
"number_member_one": "{{count}} membro",
"number_member_other": "{{count}} membri",
"number_selected": "{{count}} selezionati",
"number_selected": "{{count}} selezionato/i",
"owner": "Proprietario",
"admin": "Amministratore",
"administrator_user": "Utente amministratore",
@ -1912,7 +1912,7 @@
"organization_admin_invited_body": "Unisciti al tuo team su {{orgName}} per concentrarti sulle riunioni, non sulla loro pianificazione!",
"duplicated_slugs_warning": "Non è stato possibile creare i seguenti team a causa di slug duplicati: {{slugs}}",
"team_names_empty": "I nomi di team non possono essere vuoti",
"team_names_repeated": "I nomi di team non possono essere ripetuti",
"team_names_repeated": "I nomi di team devono essere unici",
"user_belongs_organization": "L'utente appartiene a un'organizzazione",
"org_no_teams_yet": "Questa organizzazione non ha ancora nessun team",
"org_no_teams_yet_description": "Se sei un amministratore, assicurati di creare dei team da mostrare qui.",
@ -1934,7 +1934,7 @@
"404_the_org": "L'organizzazione",
"404_the_team": "Il team",
"404_claim_entity_org": "Richiedi il sottodominio per la tua organizzazione",
"404_claim_entity_team": "Richiedi questo team e inizia a gestire le pianificazioni collettivamente",
"404_claim_entity_team": "Richiedi l'accesso a questo team per iniziare a gestire le pianificazioni collettivamente",
"insights_all_org_filter": "Tutte le app",
"insights_team_filter": "Team: {{teamName}}",
"insights_user_filter": "Utente: {{userName}}",

View File

@ -13,8 +13,8 @@
"reset_password_subject": "{{appName}}: パスワードのリセット手順",
"verify_email_subject": "{{appName}}:アカウントを確認",
"check_your_email": "メールを確認してください",
"verify_email_page_body": "{{email}} にメールを送信しました。{{appName}} からのメール配信とカレンダーが確実に連携するようにするには、メールアドレスの確認が重要です。",
"verify_email_banner_body": "メール配信とカレンダーが確実に連携するようにするには、メールアドレスを確認してください。",
"verify_email_page_body": "{{email}} にメールを送信しました。{{appName}} からのメール配信とカレンダー通知が確実に連携するようにするには、メールアドレスの確認が重要です。",
"verify_email_banner_body": "メール配信とカレンダー通知が確実に連携するようにするには、メールアドレスを確認してください。",
"verify_email_email_header": "メールアドレスの確認",
"verify_email_email_button": "メールを確認",
"verify_email_email_body": "以下のボタンをクリックして、メールアドレスを確認してください。",
@ -1934,7 +1934,7 @@
"404_the_org": "組織",
"404_the_team": "チーム",
"404_claim_entity_org": "組織のサブドメインを取得",
"404_claim_entity_team": "このチームを取得して、これからはスケジュールをまとめて管理しましょう",
"404_claim_entity_team": "このチームの一員になって、これからはスケジュールをまとめて管理しましょう",
"insights_all_org_filter": "すべて",
"insights_team_filter": "チーム:{{teamName}}",
"insights_user_filter": "ユーザー:{{userName}}",

View File

@ -13,7 +13,7 @@
"reset_password_subject": "{{appName}}: Instruções para redefinir sua senha",
"verify_email_subject": "{{appName}}: Verificar sua conta",
"check_your_email": "Verifique seu e-mail",
"verify_email_page_body": "Enviamos um e-mail para {{email}}. É importante verificar seu endereço de e-mail para garantir o fornecimento do melhor e-mail e calendário de {{appName}}.",
"verify_email_page_body": "Enviamos um e-mail para {{email}}. É importante verificar seu endereço de e-mail para garantir o melhor envio de e-mail e calendário do {{appName}}.",
"verify_email_banner_body": "Verifique seu endereço de e-mail para garantir o fornecimento do melhor e-mail e calendário",
"verify_email_email_header": "Verifique seu endereço de e-mail",
"verify_email_email_button": "Verificar e-mail",

View File

@ -310,7 +310,7 @@
"bookerlayout_user_settings_title": "Disposição da reserva",
"bookerlayout_user_settings_description": "Pode selecionar várias e os clientes podem alternar entre vistas. Isto pode ser sobreposto para cada evento.",
"bookerlayout_month_view": "Mês",
"bookerlayout_week_view": "Semanal",
"bookerlayout_week_view": "Semana",
"bookerlayout_column_view": "Coluna",
"bookerlayout_error_min_one_enabled": "Tem de estar ativa pelo menos uma disposição.",
"bookerlayout_error_default_not_enabled": "A disposição selecionada como a vista predefinida não faz parte das disposições ativas.",

View File

@ -118,7 +118,7 @@
"team_info": "Informații echipă",
"request_another_invitation_email": "Dacă preferați să nu utilizați {{toEmail}} ca e-mail {{appName}} sau aveți deja un cont {{appName}}, vă rugăm să solicitați o altă invitație la acel e-mail.",
"you_have_been_invited": "Ați fost invitat să vă alăturați echipei {{teamName}}",
"user_invited_you": "{{user}} v-a invitat să vă alăturați echipei {{entity}} {{team}} pe {{appName}}",
"user_invited_you": "{{user}} v-a invitat să vă alăturați la {{entity}} {{team}} de pe {{appName}}",
"hidden_team_member_title": "Sunteți ascuns în această echipă",
"hidden_team_member_message": "Licența dvs. nu este plătită. Fie faceți upgrade la Pro, fie anunțați proprietarul echipei că vă poate plăti licența.",
"hidden_team_owner_message": "Aveți nevoie de un cont Pro pentru a utiliza echipe. Sunteți ascuns până când faceți upgrade.",
@ -129,8 +129,8 @@
"team_upgrade_banner_description": "Vă mulțumim că ați încercat noul nostru plan pentru echipe. Am observat că planul echipei dvs. „{{teamName}}” necesită un upgrade.",
"upgrade_banner_action": "Realizați upgrade aici",
"team_upgraded_successfully": "Echipa dvs. a fost actualizată cu succes!",
"org_upgrade_banner_description": "Vă mulțumim că ați încercat planul nostru Organizație. Am observat că organizația dvs. „{{teamName}}” necesită un upgrade.",
"org_upgraded_successfully": "Upgrade-ul la organizația dvs. a fost realizat cu succes!",
"org_upgrade_banner_description": "Vă mulțumim că ați încercat planul nostru Organization. Am observat că organizația dvs. „{{teamName}}” necesită un upgrade.",
"org_upgraded_successfully": "Upgrade-ul pentru Organization a fost realizat cu succes!",
"use_link_to_reset_password": "Utilizați linkul de mai jos pentru a vă reseta parola",
"hey_there": "Bună,",
"forgot_your_password_calcom": "Ți-ai uitat parola? - {{appName}}",

View File

@ -131,7 +131,7 @@
"upgrade_banner_action": "Nadogradite ovde",
"team_upgraded_successfully": "Vaš tim je uspešno pretplaćen!",
"org_upgrade_banner_description": "Hvala što isprobavate plan naše organizacije. Primetili smo da tim vaše organizacije „{{teamName}}” treba da se nadogradi.",
"org_upgraded_successfully": "Vaša organizacija je uspešno nadograđena!",
"org_upgraded_successfully": "Vaš Organization je uspešno nadograđen!",
"use_link_to_reset_password": "Resetujte lozinku koristeći link ispod",
"hey_there": "Zdravo,",
"forgot_your_password_calcom": "Zaboravili ste lozinku? - {{appName}}",

View File

@ -129,8 +129,8 @@
"team_upgrade_banner_description": "Tack för att du testade vår nya teamplan. Vi märkte att ditt team \"{{teamName}}\" måste uppgraderas.",
"upgrade_banner_action": "Uppgradera här",
"team_upgraded_successfully": "Ditt team har uppgraderats!",
"org_upgrade_banner_description": "Tack för att du testade vår nya organisationsplan. Vi märkte att din organisation \"{{teamName}}\" måste uppgraderas.",
"org_upgraded_successfully": "Din organisation har uppgraderats!",
"org_upgrade_banner_description": "Tack för att du testade vår nya Organization-plan. Vi märkte att din Organization \"{{teamName}}\" måste uppgraderas.",
"org_upgraded_successfully": "Din Organization har uppgraderats!",
"use_link_to_reset_password": "Använd länken nedan för att återställa ditt lösenord",
"hey_there": "Hallå där,",
"forgot_your_password_calcom": "Glömt ditt lösenord? - {{appName}}",
@ -551,7 +551,7 @@
"team_description": "Några meningar om ditt team. Detta visas på ditt teams URL-sida.",
"org_description": "Några meningar om din organisation. Detta kommer att visas på din organisations webbsida.",
"members": "Medlemmar",
"organization_members": "Organisationens medlemmar",
"organization_members": "Organization-medlemmar",
"member": "Medlem",
"number_member_one": "{{count}} medlem",
"number_member_other": "{{count}} medlemmar",
@ -699,7 +699,7 @@
"create_team_to_get_started": "Skapa ett team för att komma igång",
"teams": "Teams",
"team": "Team",
"organization": "Organisation",
"organization": "Organization",
"team_billing": "Team-fakturering",
"team_billing_description": "Hantera fakturering för ditt team",
"upgrade_to_flexible_pro_title": "Vi har ändrat fakturering för team",
@ -1938,7 +1938,7 @@
"insights_all_org_filter": "Alla",
"insights_team_filter": "Team: {{teamName}}",
"insights_user_filter": "Användare: {{userName}}",
"insights_subtitle": "Visa bokningsinsikter för dina händelser",
"insights_subtitle": "Visa Insights-bokningar för dina händelser",
"custom_plan": "Anpassad plan",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Lägg till dina nya strängar här ovanför ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -1938,7 +1938,7 @@
"insights_all_org_filter": "Tümü",
"insights_team_filter": "Ekip: {{teamName}}",
"insights_user_filter": "Kullanıcı: {{userName}}",
"insights_subtitle": "Etkinlikleriniz için rezervasyon analizlerini görüntüleyin",
"insights_subtitle": "Etkinlikleriniz için rezervasyon Insights'lerini görüntüleyin",
"custom_plan": "Özel Plan",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni dizelerinizi yukarıya ekleyin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -118,7 +118,7 @@
"team_info": "Про команду",
"request_another_invitation_email": "Якщо ви не хочете використовувати {{toEmail}} в {{appName}} або вже маєте обліковий запис {{appName}}, надішліть запит на запрошення на потрібну електронну адресу.",
"you_have_been_invited": "Вас запрошено приєднатися до команди «{{teamName}}»",
"user_invited_you": "{{user}} запросив(-ла) вас приєднатися до команди «{{team}}» користувача {{entity}} в {{appName}}",
"user_invited_you": "{{user}} запросив(-ла) вас приєднатися сюди: {{entity}} «{{team}}» в {{appName}}",
"hidden_team_member_title": "У цій команді вас приховано",
"hidden_team_member_message": "Ваше місце не оплачено. Перейдіть на версію Pro або попросіть власника команди оплатити ваше місце.",
"hidden_team_owner_message": "Щоб користуватися функціями команди, потрібен обліковий запис версії Pro. До переходу на цю версію вас буде приховано.",

View File

@ -131,7 +131,7 @@
"upgrade_banner_action": "在此处升级",
"team_upgraded_successfully": "您的团队升级成功!",
"org_upgrade_banner_description": "感谢您试用我们的 Organization 计划。我们注意到您的组织“{{teamName}}”需要升级。",
"org_upgraded_successfully": "您的组织已成功升级!",
"org_upgraded_successfully": "您的 Organization 已成功升级!",
"use_link_to_reset_password": "使用下面的链接重置您的密码",
"hey_there": "嘿,您好!",
"forgot_your_password_calcom": "忘记密码?- {{appName}}",
@ -555,7 +555,7 @@
"team_description": "请写一段简单的团队介绍,该介绍将会显示在您的团队链接页面上。",
"org_description": "关于您的组织的几句话。这将显示在您组织的链接页面上。",
"members": "成员",
"organization_members": "组织成员",
"organization_members": "Organization 成员",
"member": "成员",
"number_member_one": "{{count}} 个成员",
"number_member_other": "{{count}} 个成员",
@ -1331,7 +1331,7 @@
"download_responses_description": "以 CSV 格式下载对表格的所有回复。",
"download": "下载",
"download_recording": "下载录制内容",
"recording_from_your_recent_call": "您最近在 {{appName}} 上的通话录可供下载",
"recording_from_your_recent_call": "您最近在 {{appName}} 上的通话录制内容可供下载",
"create_your_first_form": "创建您的第一个表格",
"create_your_first_form_description": "利用途径表格,您可以提出符合条件的问题,并可根据途径找到正确的人或活动类型。",
"create_your_first_webhook": "创建您的第一个 Webhook",
@ -1691,7 +1691,7 @@
"attendee_no_longer_attending": "一名参与者不再参加您的活动",
"attendee_no_longer_attending_subtitle": "{{name}} 已取消。这意味着该时段有一个位置空出来",
"create_event_on": "创建活动于",
"create_routing_form_on": "创建途径表格",
"create_routing_form_on": "创建途径表格的账户",
"default_app_link_title": "设置默认应用链接",
"default_app_link_description": "设置默认应用链接可以让所有新创建的活动类型使用您设置的应用链接。",
"organizer_default_conferencing_app": "组织者的默认应用",
@ -1880,7 +1880,7 @@
"connect_google_workspace": "连接 Google Workspace",
"google_workspace_admin_tooltip": "您必须是 Workspace 管理员才能使用此功能",
"first_event_type_webhook_description": "为此活动类型创建第一个 Webhook",
"install_app_on": "安装应用",
"install_app_on": "安装应用的账户",
"create_for": "创建",
"setup_organization": "设置组织",
"organization_banner_description": "创建一个环境,让团队可以在其中通过轮流和集体日程安排来创建共享的应用、工作流程和活动类型。",
@ -1942,7 +1942,7 @@
"insights_all_org_filter": "所有应用",
"insights_team_filter": "团队:{{teamName}}",
"insights_user_filter": "用户:{{userName}}",
"insights_subtitle": "查看您的活动的预约洞察",
"insights_subtitle": "查看您的活动的预约 insights",
"custom_plan": "自定义计划",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 在此上方添加您的新字符串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -81,21 +81,6 @@ describe("Check Booking Limits Tests", () => {
})
).rejects.toThrowError();
});
it("Should return busyTimes when set", async () => {
prismaMock.booking.count.mockResolvedValue(2);
expect(
checkBookingLimit({
key: "PER_DAY",
limitingNumber: 2,
eventStartDate: MOCK_DATA.startDate,
eventId: MOCK_DATA.id,
returnBusyTimes: true,
})
).resolves.toEqual({
start: dayjs(MOCK_DATA.startDate).startOf("day").toDate(),
end: dayjs(MOCK_DATA.startDate).endOf("day").toDate(),
});
});
});
describe("Booking limit validation", () => {

View File

@ -97,21 +97,6 @@ describe("Check Duration Limit Tests", () => {
})
).resolves.toBeUndefined();
});
it("Should return busyTimes when set and limit is reached", async () => {
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 60 }]);
await expect(
checkDurationLimit({
key: "PER_DAY",
limitingNumber: 60,
eventStartDate: MOCK_DATA.startDate,
eventId: MOCK_DATA.id,
returnBusyTimes: true,
})
).resolves.toEqual({
start: dayjs(MOCK_DATA.startDate).startOf("day").toDate(),
end: dayjs(MOCK_DATA.startDate).endOf("day").toDate(),
});
});
});
describe("Duration limit validation", () => {

View File

@ -207,4 +207,63 @@ export async function getBusyTimes(params: {
return busyTimes;
}
export async function getBusyTimesForLimitChecks(params: {
userId: number;
eventTypeId: number;
startDate: Date;
endDate: Date;
}) {
const { userId, eventTypeId, startDate, endDate } = params;
logger.silly(
`Fetch limit checks bookings in range ${startDate} to ${endDate} for input ${JSON.stringify({
userId,
eventTypeId,
status: BookingStatus.ACCEPTED,
})}`
);
performance.mark("getBusyTimesForLimitChecksStart");
const bookings = await prisma.booking.findMany({
where: {
userId,
eventTypeId,
status: BookingStatus.ACCEPTED,
// FIXME: bookings that overlap on one side will never be counted
startTime: {
gte: startDate,
},
endTime: {
lte: endDate,
},
},
select: {
id: true,
startTime: true,
endTime: true,
eventType: {
select: {
id: true,
},
},
title: true,
},
});
const busyTimes = bookings.map(({ id, startTime, endTime, eventType, title }) => ({
start: dayjs(startTime).toDate(),
end: dayjs(endTime).toDate(),
title,
source: `eventType-${eventType?.id}-booking-${id}`,
}));
logger.silly(`Fetch limit checks bookings for eventId: ${eventTypeId} ${JSON.stringify(busyTimes)}`);
performance.mark("getBusyTimesForLimitChecksEnd");
performance.measure(
`prisma booking get for limits took $1'`,
"getBusyTimesForLimitChecksStart",
"getBusyTimesForLimitChecksEnd"
);
return busyTimes;
}
export default getBusyTimes;

View File

@ -7,6 +7,7 @@ import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
import { HttpError } from "@calcom/lib/http-error";
import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
import logger from "@calcom/lib/logger";
import { checkBookingLimit } from "@calcom/lib/server";
import { performance } from "@calcom/lib/server/perfObserver";
@ -14,9 +15,14 @@ import { getTotalBookingDuration } from "@calcom/lib/server/queries";
import prisma, { availabilityUserSelect } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema, stringToDayjs } from "@calcom/prisma/zod-utils";
import type { EventBusyDetails, IntervalLimit } from "@calcom/types/Calendar";
import type {
EventBusyDate,
EventBusyDetails,
IntervalLimit,
IntervalLimitUnit,
} from "@calcom/types/Calendar";
import { getBusyTimes } from "./getBusyTimes";
import { getBusyTimes, getBusyTimesForLimitChecks } from "./getBusyTimes";
const availabilitySchema = z
.object({
@ -105,7 +111,7 @@ export const getCurrentSeats = (eventTypeId: number, dateFrom: Dayjs, dateTo: Da
export type CurrentSeats = Awaited<ReturnType<typeof getCurrentSeats>>;
/** This should be called getUsersWorkingHoursAndBusySlots (...and remaining seats, and final timezone) */
export async function getUserAvailability(
export const getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseAndEverythingElse(
query: {
withSource?: boolean;
username?: string;
@ -141,17 +147,36 @@ export async function getUserAvailability(
if (!eventType && eventTypeId) eventType = await getEventType(eventTypeId);
/* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab
current bookings with a seats event type and display them on the calendar, even if they are full */
current bookings with a seats event type and display them on the calendar, even if they are full */
let currentSeats: CurrentSeats | null = initialData?.currentSeats || null;
if (!currentSeats && eventType?.seatsPerTimeSlot) {
currentSeats = await getCurrentSeats(eventType.id, dateFrom, dateTo);
}
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
const durationLimits = parseDurationLimit(eventType?.durationLimits);
const busyTimesFromLimits =
eventType && (bookingLimits || durationLimits)
? await getBusyTimesFromLimits(
bookingLimits,
durationLimits,
dateFrom,
dateTo,
duration,
eventType,
user.id
)
: [];
// TODO: only query what we need after applying limits (shrink date range)
const getBusyTimesStart = dateFrom.toISOString();
const getBusyTimesEnd = dateTo.toISOString();
const busyTimes = await getBusyTimes({
credentials: user.credentials,
// needed to correctly apply limits (weeks can be part of two months)
startTime: dateFrom.startOf("week").toISOString(),
endTime: dateTo.endOf("week").toISOString(),
startTime: getBusyTimesStart,
endTime: getBusyTimesEnd,
eventTypeId,
userId: user.id,
username: `${user.username}`,
@ -161,40 +186,16 @@ export async function getUserAvailability(
seatedEvent: !!eventType?.seatsPerTimeSlot,
});
let bufferedBusyTimes: EventBusyDetails[] = busyTimes.map((a) => ({
...a,
start: dayjs(a.start).toISOString(),
end: dayjs(a.end).toISOString(),
title: a.title,
source: query.withSource ? a.source : undefined,
}));
const bookings = busyTimes.filter((busyTime) => busyTime.source?.startsWith(`eventType-${eventType?.id}`));
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
if (bookingLimits) {
const bookingBusyTimes = await getBusyTimesFromBookingLimits(
bookings,
bookingLimits,
dateFrom,
dateTo,
eventType
);
bufferedBusyTimes = bufferedBusyTimes.concat(bookingBusyTimes);
}
const durationLimits = parseDurationLimit(eventType?.durationLimits);
if (durationLimits) {
const durationBusyTimes = await getBusyTimesFromDurationLimits(
bookings,
durationLimits,
dateFrom,
dateTo,
duration,
eventType
);
bufferedBusyTimes = bufferedBusyTimes.concat(durationBusyTimes);
}
const detailedBusyTimes: EventBusyDetails[] = [
...busyTimes.map((a) => ({
...a,
start: dayjs(a.start).toISOString(),
end: dayjs(a.end).toISOString(),
title: a.title,
source: query.withSource ? a.source : undefined,
})),
...busyTimesFromLimits,
];
const userSchedule = user.schedules.filter(
(schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId
@ -239,22 +240,22 @@ export async function getUserAvailability(
timeZone,
});
const formattedBusyTimes = bufferedBusyTimes.map((busy) => ({
const formattedBusyTimes = detailedBusyTimes.map((busy) => ({
start: dayjs(busy.start),
end: dayjs(busy.end),
}));
return {
busy: bufferedBusyTimes,
busy: detailedBusyTimes,
timeZone,
dateRanges: subtract(dateRanges, formattedBusyTimes),
workingHours,
dateOverrides,
currentSeats,
};
}
};
const getDatesBetween = (dateFrom: Dayjs, dateTo: Dayjs, period: "day" | "week" | "month" | "year") => {
const getPeriodStartDatesBetween = (dateFrom: Dayjs, dateTo: Dayjs, period: IntervalLimitUnit) => {
const dates = [];
let startDate = dayjs(dateFrom).startOf(period);
const endDate = dayjs(dateTo).endOf(period);
@ -265,65 +266,191 @@ const getDatesBetween = (dateFrom: Dayjs, dateTo: Dayjs, period: "day" | "week"
return dates;
};
type BusyMapKey = `${IntervalLimitUnit}-${ReturnType<Dayjs["toISOString"]>}`;
/**
* Helps create, check, and return busy times from limits (with parallel support)
*/
class LimitManager {
private busyMap: Map<BusyMapKey, EventBusyDate> = new Map();
/**
* Creates a busy map key
*/
private static createKey(start: Dayjs, unit: IntervalLimitUnit): BusyMapKey {
return `${unit}-${start.startOf(unit).toISOString()}`;
}
/**
* Checks if already marked busy by ancestors or siblings
*/
isAlreadyBusy(start: Dayjs, unit: IntervalLimitUnit) {
if (this.busyMap.has(LimitManager.createKey(start, "year"))) return true;
if (unit === "month" && this.busyMap.has(LimitManager.createKey(start, "month"))) {
return true;
} else if (
unit === "week" &&
// weeks can be part of two months
((this.busyMap.has(LimitManager.createKey(start, "month")) &&
this.busyMap.has(LimitManager.createKey(start.endOf("week"), "month"))) ||
this.busyMap.has(LimitManager.createKey(start, "week")))
) {
return true;
} else if (
unit === "day" &&
(this.busyMap.has(LimitManager.createKey(start, "month")) ||
this.busyMap.has(LimitManager.createKey(start, "week")) ||
this.busyMap.has(LimitManager.createKey(start, "day")))
) {
return true;
} else {
return false;
}
}
/**
* Adds a new busy time
*/
addBusyTime(start: Dayjs, unit: IntervalLimitUnit) {
this.busyMap.set(`${unit}-${start.toISOString()}`, {
start: start.toISOString(),
end: start.endOf(unit).toISOString(),
});
}
/**
* Returns all busy times
*/
getBusyTimes() {
return Array.from(this.busyMap.values());
}
}
const getBusyTimesFromLimits = async (
bookingLimits: IntervalLimit | null,
durationLimits: IntervalLimit | null,
dateFrom: Dayjs,
dateTo: Dayjs,
duration: number | undefined,
eventType: NonNullable<EventType>,
userId: number
) => {
performance.mark("limitsStart");
// shared amongst limiters to prevent processing known busy periods
const limitManager = new LimitManager();
let limitDateFrom = dayjs(dateFrom);
let limitDateTo = dayjs(dateTo);
// expand date ranges by absolute minimum required to apply limits
// (yearly limits are handled separately for performance)
for (const key of ["PER_MONTH", "PER_WEEK", "PER_DAY"] as Exclude<keyof IntervalLimit, "PER_YEAR">[]) {
if (bookingLimits?.[key] || durationLimits?.[key]) {
const unit = intervalLimitKeyToUnit(key);
limitDateFrom = dayjs.min(limitDateFrom, dateFrom.startOf(unit));
limitDateTo = dayjs.max(limitDateTo, dateTo.endOf(unit));
}
}
// fetch only the data we need to check limits
const bookings = await getBusyTimesForLimitChecks({
userId,
eventTypeId: eventType.id,
startDate: limitDateFrom.toDate(),
endDate: limitDateTo.toDate(),
});
// run this first, as counting bookings should always run faster..
if (bookingLimits) {
performance.mark("bookingLimitsStart");
await getBusyTimesFromBookingLimits(
bookings,
bookingLimits,
dateFrom,
dateTo,
eventType.id,
limitManager
);
performance.mark("bookingLimitsEnd");
performance.measure(`checking booking limits took $1'`, "bookingLimitsStart", "bookingLimitsEnd");
}
// ..than adding up durations (especially for the whole year)
if (durationLimits) {
performance.mark("durationLimitsStart");
await getBusyTimesFromDurationLimits(
bookings,
durationLimits,
dateFrom,
dateTo,
duration,
eventType,
limitManager
);
performance.mark("durationLimitsEnd");
performance.measure(`checking duration limits took $1'`, "durationLimitsStart", "durationLimitsEnd");
}
performance.mark("limitsEnd");
performance.measure(`checking all limits took $1'`, "limitsStart", "limitsEnd");
return limitManager.getBusyTimes();
};
const getBusyTimesFromBookingLimits = async (
bookings: EventBusyDetails[],
bookingLimits: IntervalLimit,
dateFrom: Dayjs,
dateTo: Dayjs,
eventType: EventType | undefined
eventTypeId: number,
limitManager: LimitManager
) => {
const busyTimes: EventBusyDetails[] = [];
for (const key of descendingLimitKeys) {
const limit = bookingLimits?.[key];
if (!limit) continue;
// Apply booking limit filter against our bookings
for (const [key, limit] of Object.entries(bookingLimits)) {
const limitKey = key as keyof IntervalLimit;
const unit = intervalLimitKeyToUnit(key);
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit);
if (limitKey === "PER_YEAR") {
const yearlyBusyTime = await checkBookingLimit({
eventStartDate: dateFrom.toDate(),
limitingNumber: limit,
eventId: eventType?.id as number,
key: "PER_YEAR",
returnBusyTimes: true,
});
if (!yearlyBusyTime) break;
busyTimes.push({
start: yearlyBusyTime.start.toISOString(),
end: yearlyBusyTime.end.toISOString(),
});
break;
}
for (const periodStart of periodStartDates) {
if (limitManager.isAlreadyBusy(periodStart, unit)) continue;
// Take PER_DAY and turn it into day and PER_WEEK into week etc.
const filter = key.split("_")[1].toLowerCase() as "day" | "week" | "month" | "year";
const dates = getDatesBetween(dateFrom, dateTo, filter);
// special handling of yearly limits to improve performance
if (unit === "year") {
try {
await checkBookingLimit({
eventStartDate: periodStart.toDate(),
limitingNumber: limit,
eventId: eventTypeId,
key,
});
} catch (_) {
limitManager.addBusyTime(periodStart, unit);
if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) {
return;
}
}
continue;
}
const periodEnd = periodStart.endOf(unit);
let totalBookings = 0;
// loop through all dates and check if we have reached the limit
for (const date of dates) {
let total = 0;
const startDate = date.startOf(filter);
// this is parsed above with parseBookingLimit so we know it's safe.
const endDate = date.endOf(filter);
for (const booking of bookings) {
const bookingEventTypeId = parseInt(booking.source?.split("-")[1] as string, 10);
if (
// Only check OUR booking that matches the current eventTypeId
// we don't care about another event type in this case as we dont need to know their booking limits
!(bookingEventTypeId == eventType?.id && dayjs(booking.start).isBetween(startDate, endDate))
) {
// consider booking part of period independent of end date
if (!dayjs(booking.start).isBetween(periodStart, periodEnd)) {
continue;
}
// increment total and check against the limit, adding a busy time if condition is met.
total++;
if (total >= limit) {
busyTimes.push({ start: startDate.toISOString(), end: endDate.toISOString() });
totalBookings++;
if (totalBookings >= limit) {
limitManager.addBusyTime(periodStart, unit);
break;
}
}
}
}
return busyTimes;
};
const getBusyTimesFromDurationLimits = async (
@ -332,60 +459,56 @@ const getBusyTimesFromDurationLimits = async (
dateFrom: Dayjs,
dateTo: Dayjs,
duration: number | undefined,
eventType: EventType | undefined
eventType: NonNullable<EventType>,
limitManager: LimitManager
) => {
const busyTimes: EventBusyDetails[] = [];
// Start check from larger time periods to smaller time periods, to skip unnecessary checks
for (const [key, limit] of Object.entries(durationLimits).reverse()) {
// Use aggregate sql query if we are checking PER_YEAR
if (key === "PER_YEAR") {
const totalBookingDuration = await getTotalBookingDuration({
eventId: eventType?.id as number,
startDate: dateFrom.startOf("year").toDate(),
endDate: dateFrom.endOf("year").toDate(),
});
if (totalBookingDuration + (duration ?? 0) > limit) {
busyTimes.push({
start: dateFrom.startOf("year").toISOString(),
end: dateFrom.endOf("year").toISOString(),
});
}
continue;
}
for (const key of descendingLimitKeys) {
const limit = durationLimits?.[key];
if (!limit) continue;
const filter = key.split("_")[1].toLowerCase() as "day" | "week" | "month" | "year";
const dates = getDatesBetween(dateFrom, dateTo, filter);
const unit = intervalLimitKeyToUnit(key);
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit);
// loop through all dates and check if we have reached the limit
for (const date of dates) {
let total = (duration || eventType?.length) ?? 0;
const startDate = date.startOf(filter);
const endDate = date.endOf(filter);
for (const periodStart of periodStartDates) {
if (limitManager.isAlreadyBusy(periodStart, unit)) continue;
// add busy time if we have already reached the limit with just the selected duration
if (total > limit) {
busyTimes.push({ start: startDate.toISOString(), end: endDate.toISOString() });
const selectedDuration = (duration || eventType.length) ?? 0;
if (selectedDuration > limit) {
limitManager.addBusyTime(periodStart, unit);
continue;
}
// special handling of yearly limits to improve performance
if (unit === "year") {
const totalYearlyDuration = await getTotalBookingDuration({
eventId: eventType.id,
startDate: periodStart.toDate(),
endDate: periodStart.endOf(unit).toDate(),
});
if (totalYearlyDuration + selectedDuration > limit) {
limitManager.addBusyTime(periodStart, unit);
if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) {
return;
}
}
continue;
}
const periodEnd = periodStart.endOf(unit);
let totalDuration = selectedDuration;
for (const booking of bookings) {
const bookingEventTypeId = parseInt(booking.source?.split("-")[1] as string, 10);
if (
// Only check OUR booking that matches the current eventTypeId
// we don't care about another event type in this case as we dont need to know their booking limits
!(bookingEventTypeId == eventType?.id && dayjs(booking.start).isBetween(startDate, endDate))
) {
// consider booking part of period independent of end date
if (!dayjs(booking.start).isBetween(periodStart, periodEnd)) {
continue;
}
// Add current booking duration to total and check against the limit, adding a busy time if condition is met.
total += dayjs(booking.end).diff(dayjs(booking.start), "minute");
if (total > limit) {
busyTimes.push({ start: startDate.toISOString(), end: endDate.toISOString() });
totalDuration += dayjs(booking.end).diff(dayjs(booking.start), "minute");
if (totalDuration > limit) {
limitManager.addBusyTime(periodStart, unit);
break;
}
}
}
}
return busyTimes;
};

View File

@ -0,0 +1,17 @@
import { useRef } from "react";
/**
* Updates in document the number of times a component has been rendered. Helps in 2 ways. Using it doesn't cause any additional renders.
* - Did the component render when it shouldn't have?
* - Did the component reset its state when it shouldn't have?
*/
export const RenderCounter = ({ label }: { label: string }) => {
const counterRef = useRef(0);
counterRef.current++;
return (
<span>
<span>{label}:</span>
<span>{counterRef.current} </span>
</span>
);
};

View File

@ -0,0 +1 @@
export { RenderCounter } from "./components/RenderCounter";

View File

@ -0,0 +1,7 @@
{
"name": "@calcom/debugging",
"description": "Debugging utilities",
"private": true,
"version": "1.0.0",
"main": "./index.ts"
}

View File

@ -0,0 +1,15 @@
{
"extends": "@calcom/tsconfig/react-library.json",
"compilerOptions": {
"resolveJsonModule": true
},
"include": [
"../../apps/web/next-env.d.ts",
"../types/*.d.ts",
"../types/next-auth.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": ["dist", "build", "node_modules"]
}

View File

@ -0,0 +1,17 @@
import type { ComponentProps } from "react";
import React from "react";
import Shell from "@calcom/features/shell/Shell";
export default function MainLayout({
children,
...rest
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
return (
<Shell withoutMain={true} {...rest}>
{children}
</Shell>
);
}
export const getLayout = (page: React.ReactElement) => <MainLayout>{page}</MainLayout>;

View File

@ -298,7 +298,7 @@ const BookerComponent = ({
<m.span
key="logo"
className={classNames(
"mb-6 mt-auto pt-6 [&_img]:h-[15px]",
"-z-10 mb-6 mt-auto pt-6 [&_img]:h-[15px]",
hasDarkBackground ? "dark" : "",
layout === BookerLayouts.MONTH_VIEW ? "block" : "hidden"
)}>

View File

@ -1,51 +0,0 @@
import type { ComponentProps } from "react";
import React from "react";
import Shell from "@calcom/features/shell/Shell";
import { HorizontalTabs } from "@calcom/ui";
import type { VerticalTabItemProps, HorizontalTabItemProps } from "@calcom/ui";
import { FiltersContainer } from "../components/FiltersContainer";
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
{
name: "upcoming",
href: "/bookings/upcoming",
},
{
name: "unconfirmed",
href: "/bookings/unconfirmed",
},
{
name: "recurring",
href: "/bookings/recurring",
},
{
name: "past",
href: "/bookings/past",
},
{
name: "cancelled",
href: "/bookings/cancelled",
},
];
export default function BookingLayout({
children,
...rest
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
return (
<Shell {...rest} hideHeadingOnMobile>
<div className="flex flex-col">
<div className="flex flex-col flex-wrap lg:flex-row">
<HorizontalTabs tabs={tabs} />
<div className="max-w-full overflow-x-auto xl:ml-auto">
<FiltersContainer />
</div>
</div>
<main className="w-full">{children}</main>
</div>
</Shell>
);
}
export const getLayout = (page: React.ReactElement) => <BookingLayout>{page}</BookingLayout>;

View File

@ -652,7 +652,6 @@ async function handler(
const fullName = getFullName(bookerName);
const tAttendees = await getTranslation(language ?? "en", "common");
const tGuests = await getTranslation("en", "common");
log.debug(`Booking eventType ${eventTypeId} started`);
const dynamicUserList = Array.isArray(reqBody.user) ? reqBody.user : getUsernameList(reqBody.user);
@ -848,6 +847,46 @@ async function handler(
const allCredentials = await getAllCredentials(organizerUser, eventType);
let rescheduleUid = reqBody.rescheduleUid;
let bookingSeat: Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null = null;
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
let originalRescheduledBooking: BookingType = null;
if (rescheduleUid) {
// rescheduleUid can be bookingUid and bookingSeatUid
bookingSeat = await prisma.bookingSeat.findUnique({
where: {
referenceUid: rescheduleUid,
},
include: {
booking: true,
attendee: true,
},
});
if (bookingSeat) {
rescheduleUid = bookingSeat.booking.uid;
}
originalRescheduledBooking = await getOriginalRescheduledBooking(
rescheduleUid,
!!eventType.seatsPerTimeSlot
);
if (!originalRescheduledBooking) {
throw new HttpError({ statusCode: 404, message: "Could not find original booking" });
}
}
const isOrganizerRescheduling = organizerUser.id === userId;
const attendeeInfoOnReschedule =
isOrganizerRescheduling && originalRescheduledBooking
? originalRescheduledBooking.attendees.find((attendee) => attendee.email === bookerEmail)
: null;
const attendeeLanguage = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.locale : language;
const attendeeTimezone = attendeeInfoOnReschedule ? attendeeInfoOnReschedule.timeZone : reqBody.timeZone;
const tAttendees = await getTranslation(attendeeLanguage ?? "en", "common");
// use host default
if (isTeamEventType && locationBodyString === OrganizerDefaultConferencingAppType) {
const metadataParseResult = userMetadataSchema.safeParse(organizerUser.metadata);
@ -868,8 +907,8 @@ async function handler(
name: fullName,
firstName: (typeof bookerName === "object" && bookerName.firstName) || "",
lastName: (typeof bookerName === "object" && bookerName.lastName) || "",
timeZone: reqBody.timeZone,
language: { translate: tAttendees, locale: language ?? "en" },
timeZone: attendeeTimezone,
language: { translate: tAttendees, locale: attendeeLanguage ?? "en" },
},
];
@ -883,7 +922,7 @@ async function handler(
name: "",
firstName: "",
lastName: "",
timeZone: reqBody.timeZone,
timeZone: attendeeTimezone,
language: { translate: tGuests, locale: "en" },
});
return guestArray;
@ -982,34 +1021,6 @@ async function handler(
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
};
let rescheduleUid = reqBody.rescheduleUid;
let bookingSeat: Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null = null;
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
let originalRescheduledBooking: BookingType = null;
if (rescheduleUid) {
// rescheduleUid can be bookingUid and bookingSeatUid
bookingSeat = await prisma.bookingSeat.findUnique({
where: {
referenceUid: rescheduleUid,
},
include: {
booking: true,
attendee: true,
},
});
if (bookingSeat) {
rescheduleUid = bookingSeat.booking.uid;
}
originalRescheduledBooking = await getOriginalRescheduledBooking(
rescheduleUid,
!!eventType.seatsPerTimeSlot
);
if (!originalRescheduledBooking) {
throw new HttpError({ statusCode: 404, message: "Could not find original booking" });
}
}
/* Used for seats bookings to update evt object with video data */
const addVideoCallDataToEvt = (bookingReferences: BookingReference[]) => {
const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video"));
@ -1397,7 +1408,7 @@ async function handler(
const updatedBookingAttendees = updatedNewBooking.attendees.map((attendee) => {
const evtAttendee = {
...attendee,
language: { translate: tAttendees, locale: language ?? "en" },
language: { translate: tAttendees, locale: attendeeLanguage ?? "en" },
};
return evtAttendee;
});
@ -1529,7 +1540,7 @@ async function handler(
} else {
// Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language
const bookingAttendees = booking.attendees.map((attendee) => {
return { ...attendee, language: { translate: tAttendees, locale: language ?? "en" } };
return { ...attendee, language: { translate: tAttendees, locale: attendeeLanguage ?? "en" } };
});
evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] };

View File

@ -7,6 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { DestinationCalendar } from "@calcom/prisma/client";
import { trpc } from "@calcom/trpc/react";
import { Select } from "@calcom/ui";
import { Check } from "@calcom/ui/components/icon";
interface Props {
onChange: (value: { externalId: string; integration: string }) => void;
@ -37,7 +38,10 @@ const OptionComponent = ({ ...props }: OptionProps<Option>) => {
const { label } = props.data;
return (
<components.Option {...props}>
<span>{label}</span>
<div className="flex">
<span className="mr-auto">{label}</span>
{props.isSelected && <Check className="ml-2 h-4 w-4" />}
</div>
</components.Option>
);
};

View File

@ -34,14 +34,28 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
where: { metadata: { path: ["paymentId"], equals: checkoutSession.id } },
});
let metadata;
if (!team) {
const prevTeam = await prisma.team.findFirstOrThrow({ where: { id } });
const metadata = teamMetadataSchema.parse(prevTeam.metadata);
metadata = teamMetadataSchema.safeParse(prevTeam.metadata);
if (!metadata.success) throw new HttpError({ statusCode: 400, message: "Invalid team metadata" });
if (!metadata.data?.requestedSlug) {
throw new HttpError({
statusCode: 400,
message: "Can't publish team/org without `requestedSlug`",
});
}
const { requestedSlug, ...newMetadata } = metadata.data;
/** We save the metadata first to prevent duplicate payments */
team = await prisma.team.update({
where: { id },
data: {
metadata: {
...newMetadata,
paymentId: checkoutSession.id,
subscriptionId: subscription.id || null,
subscriptionItemId: subscription.items.data[0].id || null,
@ -49,7 +63,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
},
});
/** Legacy teams already have a slug, this will allow them to upgrade as well */
const slug = prevTeam.slug || metadata?.requestedSlug;
const slug = prevTeam.slug || requestedSlug;
if (slug) {
try {
/** Then we try to upgrade the slug, which may fail if a conflict came up since team creation */
@ -64,12 +78,21 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
closeComUpdateTeam(prevTeam, team);
}
if (!metadata) {
metadata = teamMetadataSchema.safeParse(team.metadata);
if (!metadata.success) throw new HttpError({ statusCode: 400, message: "Invalid team metadata" });
}
const session = await getServerSession({ req, res });
if (!session) return { message: "Team upgraded successfully" };
const redirectUrl = metadata?.data?.isOrganization
? `${WEBAPP_URL}/settings/organizations/profile?upgraded=true`
: `${WEBAPP_URL}/settings/teams/${team.id}/profile?upgraded=true`;
// redirect to team screen
res.redirect(302, `${WEBAPP_URL}/settings/teams/${team.id}/profile?upgraded=true`);
res.redirect(302, redirectUrl);
}
export default defaultHandler({

View File

@ -29,7 +29,6 @@ import {
} from "@calcom/ui";
import { ExternalLink, MoreHorizontal, Edit2, Lock, UserX } from "@calcom/ui/components/icon";
import { useOrgBranding } from "../../organizations/context/provider";
import MemberChangeRoleModal from "./MemberChangeRoleModal";
import TeamAvailabilityModal from "./TeamAvailabilityModal";
import TeamPill, { TeamRole } from "./TeamPill";
@ -54,7 +53,6 @@ const checkIsOrg = (team: Props["team"]) => {
export default function MemberListItem(props: Props) {
const { t } = useLocale();
const orgBranding = useOrgBranding();
const utils = trpc.useContext();
const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false);
@ -115,7 +113,7 @@ export default function MemberListItem(props: Props) {
return (
<li className="divide-subtle divide-y px-5">
<div className="my-4 flex justify-between">
<div className="flex w-full flex-col justify-between sm:flex-row">
<div className="flex w-full flex-col justify-between truncate sm:flex-row">
<div className="flex">
<Avatar
size="sm"

View File

@ -109,7 +109,7 @@ function UsersTableBare() {
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div
className="rounded-md border"
className="border-subtle rounded-md border"
ref={tableContainerRef}
onScroll={() => fetchMoreOnBottomReached()}
style={{

View File

@ -3,7 +3,8 @@ import { useRouter } from "next/navigation";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import Shell from "@calcom/features/shell/Shell";
import { getLayout } from "@calcom/features/MainLayout";
import { ShellMain } from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -49,7 +50,7 @@ function WorkflowsPage() {
});
return (
<Shell
<ShellMain
heading={t("workflows")}
title={t("workflows")}
subtitle={t("workflows_to_automate_notifications")}
@ -92,7 +93,7 @@ function WorkflowsPage() {
</FilterResults>
</>
</LicenseRequired>
</Shell>
</ShellMain>
);
}
@ -221,4 +222,6 @@ const Filter = (props: {
);
};
WorkflowsPage.getLayout = getLayout;
export default WorkflowsPage;

View File

@ -39,7 +39,7 @@ export const CheckedTeamSelect = ({
{/* This class name conditional looks a bit odd but it allows a seemless transition when using autoanimate
- Slides down from the top instead of just teleporting in from nowhere*/}
<ul
className={classNames("mt-3 rounded-md", value.length >= 1 && "border-subtle border")}
className={classNames("mb-1 mt-3 rounded-md", value.length >= 1 && "border-subtle border")}
ref={animationRef}>
{value.map((option, index) => (
<li

View File

@ -69,7 +69,6 @@ const ScheduleDay = <TFieldValues extends FieldValues>({
/>
</div>
<span className="inline-block min-w-[88px] text-sm capitalize">{weekday}</span>
{watchDayRange && !!watchDayRange.length && <div className="sm:hidden">{CopyButton}</div>}
</label>
</div>
</div>
@ -77,7 +76,7 @@ const ScheduleDay = <TFieldValues extends FieldValues>({
{watchDayRange ? (
<div className="flex sm:ml-2">
<DayRanges control={control} name={name} />
{!!watchDayRange.length && <div className="hidden sm:block">{CopyButton}</div>}
{!!watchDayRange.length && <div className="block">{CopyButton}</div>}
</div>
) : (
<SkeletonText className="ml-1 mt-2.5 h-6 w-48" />
@ -244,7 +243,7 @@ const RemoveTimeButton = ({
const TimeRangeField = ({ className, value, onChange }: { className?: string } & ControllerRenderProps) => {
// this is a controlled component anyway given it uses LazySelect, so keep it RHF agnostic.
return (
<div className={className}>
<div className={classNames("flex flex-row gap-1",className)}>
<LazySelect
className="inline-block w-[100px]"
value={value.start}

View File

@ -12,6 +12,7 @@ import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import UnconfirmedBookingBadge from "@calcom/features/bookings/UnconfirmedBookingBadge";
import ImpersonatingBanner from "@calcom/features/ee/impersonation/components/ImpersonatingBanner";
import { OrgUpgradeBanner } from "@calcom/features/ee/organizations/components/OrgUpgradeBanner";
import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains";
import HelpMenuItem from "@calcom/features/ee/support/components/HelpMenuItem";
import { TeamsUpgradeBanner } from "@calcom/features/ee/teams/components";
import { useFlagMap } from "@calcom/features/flags/context/provider";
@ -788,9 +789,6 @@ function SideBarContainer({ bannersHeight }: SideBarContainerProps) {
return <SideBar bannersHeight={bannersHeight} user={data?.user} />;
}
const getOrganizationUrl = (slug: string) =>
`${slug}.${process.env.NEXT_PUBLIC_WEBSITE_URL?.replace?.(/http(s*):\/\//, "")}`;
function SideBar({ bannersHeight, user }: SideBarProps) {
const { t, isLocaleReady } = useLocale();
const orgBranding = useOrgBranding();
@ -798,7 +796,7 @@ function SideBar({ bannersHeight, user }: SideBarProps) {
const publicPageUrl = useMemo(() => {
if (!user?.organizationId) return `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user?.username}`;
const publicPageUrl = orgBranding?.slug ? getOrganizationUrl(orgBranding?.slug) : "";
const publicPageUrl = orgBranding?.slug ? getOrgFullDomain(orgBranding.slug) : "";
return publicPageUrl;
}, [orgBranding?.slug, user?.organizationId, user?.username]);

View File

@ -4,6 +4,8 @@ import { prisma } from "@calcom/prisma";
import type { Prisma } from ".prisma/client";
type EnabledApp = ReturnType<typeof getApps>[number] & { enabled: boolean };
/**
*
* @param credentials - Can be user or team credentials
@ -39,14 +41,13 @@ const getEnabledApps = async (credentials: CredentialDataWithTeamName[], filterO
select: { slug: true, enabled: true },
});
const apps = getApps(credentials, filterOnCredentials);
const filteredApps = enabledApps.reduce((reducedArray, app) => {
const appMetadata = apps.find((metadata) => metadata.slug === app.slug);
if (appMetadata) {
reducedArray.push({ ...appMetadata, enabled: app.enabled });
const filteredApps = apps.reduce((reducedArray, app) => {
const appDbQuery = enabledApps.find((metadata) => metadata.slug === app.slug);
if (appDbQuery?.enabled || app.isGlobal) {
reducedArray.push({ ...app, enabled: true });
}
return reducedArray;
}, [] as (ReturnType<typeof getApps>[number] & { enabled: boolean })[]);
}, [] as EnabledApp[]);
return filteredApps;
};

View File

@ -29,6 +29,36 @@ describe("processWorkingHours", () => {
end: dayjs(`${dateTo.tz(timeZone).format("YYYY-MM-DD")}T21:00:00Z`).tz(timeZone),
});
});
it("should return the correct working hours in the month were DST ends", () => {
const item = {
days: [0, 1, 2, 3, 4, 5, 6], // Monday to Sunday
startTime: new Date(Date.UTC(2023, 5, 12, 8, 0)), // 8 AM
endTime: new Date(Date.UTC(2023, 5, 12, 17, 0)), // 5 PM
};
// in America/New_York DST ends on first Sunday of November
const timeZone = "America/New_York";
let firstSundayOfNovember = dayjs().startOf("day").month(10).date(1);
while (firstSundayOfNovember.day() !== 0) {
firstSundayOfNovember = firstSundayOfNovember.add(1, "day");
}
const dateFrom = dayjs().month(10).date(1).startOf("day");
const dateTo = dayjs().month(10).endOf("month");
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo });
const allDSTStartAt12 = results
.filter((res) => res.start.isBefore(firstSundayOfNovember))
.every((result) => result.start.utc().hour() === 12);
const allNotDSTStartAt13 = results
.filter((res) => res.start.isAfter(firstSundayOfNovember))
.every((result) => result.start.utc().hour() === 13);
expect(allDSTStartAt12).toBeTruthy();
expect(allNotDSTStartAt13).toBeTruthy();
});
});
describe("processDateOverrides", () => {

View File

@ -23,12 +23,21 @@ export function processWorkingHours({
}) {
const results = [];
for (let date = dateFrom.tz(timeZone).startOf("day"); dateTo.isAfter(date); date = date.add(1, "day")) {
if (!item.days.includes(date.day())) {
const dateInTz = date.tz(timeZone);
if (!item.days.includes(dateInTz.day())) {
continue;
}
const start = date.hour(item.startTime.getUTCHours()).minute(item.startTime.getUTCMinutes()).second(0);
const end = date.hour(item.endTime.getUTCHours()).minute(item.endTime.getUTCMinutes()).second(0);
let start = dateInTz.hour(item.startTime.getUTCHours()).minute(item.startTime.getUTCMinutes()).second(0);
let end = dateInTz.hour(item.endTime.getUTCHours()).minute(item.endTime.getUTCMinutes()).second(0);
const offsetBeginningOfDay = dayjs(start.format("YYYY-MM-DD hh:mm")).tz(timeZone).utcOffset();
const offsetDiff = start.utcOffset() - offsetBeginningOfDay; // there will be 60 min offset on the day day of DST change
start = start.add(offsetDiff, "minute");
end = end.add(offsetDiff, "minute");
const startResult = dayjs.max(start, dateFrom.tz(timeZone));
const endResult = dayjs.min(end, dateTo.tz(timeZone));

View File

@ -0,0 +1,12 @@
import type { IntervalLimit, IntervalLimitUnit } from "@calcom/types/Calendar";
export const ascendingLimitKeys: (keyof IntervalLimit)[] = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"];
export const descendingLimitKeys = [...ascendingLimitKeys].reverse();
/**
* Turns `PER_DAY` into `day`, `PER_WEEK` into `week` etc.
*/
export function intervalLimitKeyToUnit(key: keyof IntervalLimit) {
return key.split("_")[1].toLowerCase() as IntervalLimitUnit;
}

View File

@ -1,34 +1,31 @@
import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { IntervalLimit } from "@calcom/types/Calendar";
import { getErrorFromUnknown } from "../errors";
import { HttpError } from "../http-error";
import { ascendingLimitKeys, intervalLimitKeyToUnit } from "../intervalLimit";
import { parseBookingLimit } from "../isBookingLimits";
export async function checkBookingLimits(
bookingLimits: IntervalLimit,
eventStartDate: Date,
eventId: number,
returnBusyTimes?: boolean
eventId: number
) {
const parsedBookingLimits = parseBookingLimit(bookingLimits);
if (parsedBookingLimits) {
const limitCalculations = Object.entries(parsedBookingLimits).map(
async ([key, limitingNumber]) =>
await checkBookingLimit({ key, limitingNumber, eventStartDate, eventId, returnBusyTimes })
);
await Promise.all(limitCalculations)
.then((res) => {
if (returnBusyTimes) {
return res;
}
})
.catch((error) => {
throw new HttpError({ message: error.message, statusCode: 401 });
});
return true;
if (!parsedBookingLimits) return false;
// not iterating entries to preserve types
const limitCalculations = ascendingLimitKeys.map((key) =>
checkBookingLimit({ key, limitingNumber: parsedBookingLimits[key], eventStartDate, eventId })
);
try {
return !!(await Promise.all(limitCalculations));
} catch (error) {
throw new HttpError({ message: getErrorFromUnknown(error).message, statusCode: 401 });
}
return false;
}
export async function checkBookingLimit({
@ -36,59 +33,39 @@ export async function checkBookingLimit({
eventId,
key,
limitingNumber,
returnBusyTimes = false,
}: {
eventStartDate: Date;
eventId: number;
key: string;
limitingNumber: number;
returnBusyTimes?: boolean;
key: keyof IntervalLimit;
limitingNumber: number | undefined;
}) {
{
const limitKey = key as keyof IntervalLimit;
// Take PER_DAY and turn it into day and PER_WEEK into week etc.
const filter = limitKey.split("_")[1].toLocaleLowerCase() as "day" | "week" | "month" | "year"; // Have to cast here
const startDate = dayjs(eventStartDate).startOf(filter).toDate();
// this is parsed above with parseBookingLimit so we know it's safe.
if (!limitingNumber) return;
const endDate = dayjs(startDate).endOf(filter).toDate();
const unit = intervalLimitKeyToUnit(key);
const startDate = dayjs(eventStartDate).startOf(unit).toDate();
const endDate = dayjs(eventStartDate).endOf(unit).toDate();
// This allows us to easily add it within dayjs
const bookingsInPeriod = await prisma.booking.count({
where: {
status: "ACCEPTED",
eventType: {
AND: [
{
id: eventId,
bookings: {
every: {
startTime: {
gte: startDate,
},
endTime: {
lte: endDate,
},
},
},
},
],
status: BookingStatus.ACCEPTED,
eventTypeId: eventId,
// FIXME: bookings that overlap on one side will never be counted
startTime: {
gte: startDate,
},
endTime: {
lte: endDate,
},
},
});
if (bookingsInPeriod >= limitingNumber) {
// This is used when getting availability
if (returnBusyTimes) {
return {
start: startDate,
end: endDate,
};
}
throw new HttpError({
message: `booking_limit_reached`,
statusCode: 403,
});
}
if (bookingsInPeriod < limitingNumber) return;
throw new HttpError({
message: `booking_limit_reached`,
statusCode: 403,
});
}
}

View File

@ -1,7 +1,9 @@
import dayjs from "@calcom/dayjs";
import type { IntervalLimit } from "@calcom/types/Calendar";
import { getErrorFromUnknown } from "../errors";
import { HttpError } from "../http-error";
import { ascendingLimitKeys, intervalLimitKeyToUnit } from "../intervalLimit";
import { parseDurationLimit } from "../isDurationLimits";
import { getTotalBookingDuration } from "./queries";
@ -11,20 +13,18 @@ export async function checkDurationLimits(
eventId: number
) {
const parsedDurationLimits = parseDurationLimit(durationLimits);
if (!parsedDurationLimits) {
return false;
}
if (!parsedDurationLimits) return false;
const limitCalculations = Object.entries(parsedDurationLimits).map(
async ([key, limitingNumber]) =>
await checkDurationLimit({ key, limitingNumber, eventStartDate, eventId })
// not iterating entries to preserve types
const limitCalculations = ascendingLimitKeys.map((key) =>
checkDurationLimit({ key, limitingNumber: parsedDurationLimits[key], eventStartDate, eventId })
);
await Promise.all(limitCalculations).catch((error) => {
throw new HttpError({ message: error.message, statusCode: 401 });
});
return true;
try {
return !!(await Promise.all(limitCalculations));
} catch (error) {
throw new HttpError({ message: getErrorFromUnknown(error).message, statusCode: 401 });
}
}
export async function checkDurationLimit({
@ -32,34 +32,27 @@ export async function checkDurationLimit({
eventId,
key,
limitingNumber,
returnBusyTimes = false,
}: {
eventStartDate: Date;
eventId: number;
key: string;
limitingNumber: number;
returnBusyTimes?: boolean;
key: keyof IntervalLimit;
limitingNumber: number | undefined;
}) {
{
// Take PER_DAY and turn it into day and PER_WEEK into week etc.
const filter = key.split("_")[1].toLocaleLowerCase() as "day" | "week" | "month" | "year";
const startDate = dayjs(eventStartDate).startOf(filter).toDate();
const endDate = dayjs(startDate).endOf(filter).toDate();
if (!limitingNumber) return;
const unit = intervalLimitKeyToUnit(key);
const startDate = dayjs(eventStartDate).startOf(unit).toDate();
const endDate = dayjs(eventStartDate).endOf(unit).toDate();
const totalBookingDuration = await getTotalBookingDuration({ eventId, startDate, endDate });
if (totalBookingDuration >= limitingNumber) {
// This is used when getting availability
if (returnBusyTimes) {
return {
start: startDate,
end: endDate,
};
}
throw new HttpError({
message: `duration_limit_reached`,
statusCode: 403,
});
}
if (totalBookingDuration < limitingNumber) return;
throw new HttpError({
message: `duration_limit_reached`,
statusCode: 403,
});
}
}

View File

@ -10,14 +10,15 @@ export const getTotalBookingDuration = async ({
endDate: Date;
}) => {
// Aggregates the total booking time for a given event in a given time period
const [totalBookingTime] = (await prisma.$queryRaw`
// FIXME: bookings that overlap on one side will never be counted
const [totalBookingTime] = await prisma.$queryRaw<[{ totalMinutes: number | null }]>`
SELECT SUM(EXTRACT(EPOCH FROM ("endTime" - "startTime")) / 60) as "totalMinutes"
FROM "Booking"
WHERE "status" = 'accepted'
AND "id" = ${eventId}
AND "eventTypeId" = ${eventId}
AND "startTime" >= ${startDate}
AND "endTime" <= ${endDate};
`) as { totalMinutes: number }[];
`;
return totalBookingTime.totalMinutes;
return totalBookingTime.totalMinutes ?? 0;
};

View File

@ -99,7 +99,7 @@ export async function getTeamWithMembers(args: {
const where: Prisma.TeamFindFirstArgs["where"] = {};
if (userId) where.members = { some: { userId } };
if (orgSlug) {
if (orgSlug && orgSlug !== slug) {
where.parent = getSlugOrRequestedSlug(orgSlug);
}
if (id) where.id = id;

View File

@ -1,6 +1,7 @@
import type { IntervalLimit } from "@calcom/types/Calendar";
const validationOrderKeys = ["PER_DAY", "PER_WEEK", "PER_MONTH", "PER_YEAR"];
import { ascendingLimitKeys } from "./intervalLimit";
export const validateIntervalLimitOrder = (input: IntervalLimit) => {
// Sort limits by validationOrder
const sorted = Object.entries(input)
@ -9,7 +10,7 @@ export const validateIntervalLimitOrder = (input: IntervalLimit) => {
})
.map(([key]) => key);
const validationOrderWithoutMissing = validationOrderKeys.filter((key) => sorted.includes(key));
const validationOrderWithoutMissing = ascendingLimitKeys.filter((key) => sorted.includes(key));
return sorted.every((key, index) => validationOrderWithoutMissing[index] === key);
};

View File

@ -120,12 +120,9 @@ export interface RecurringEvent {
tzid?: string | undefined;
}
export interface IntervalLimit {
PER_DAY?: number | undefined;
PER_WEEK?: number | undefined;
PER_MONTH?: number | undefined;
PER_YEAR?: number | undefined;
}
export type IntervalLimitUnit = "day" | "week" | "month" | "year";
export type IntervalLimit = Partial<Record<`PER_${Uppercase<IntervalLimitUnit>}`, number | undefined>>;
export type AppsStatus = {
appName: string;

View File

@ -1,6 +1,6 @@
const StepCard: React.FC<{ children: React.ReactNode }> = (props) => {
return (
<div className="border-subtle bg-default mt-10 rounded-md border p-4 dark:bg-black sm:p-8">
<div className="sm:border-subtle bg-default mt-10 border p-4 dark:bg-black sm:rounded-md sm:p-8">
{props.children}
</div>
);

View File

@ -101,7 +101,7 @@ export function DataTable<TData, TValue>({
tableCTA={tableCTA}
/>
<div
className="rounded-md border"
className="border-subtle rounded-md border"
ref={tableContainerRef}
onScroll={onScroll}
style={{

View File

@ -10,7 +10,7 @@ export interface NavTabProps {
const HorizontalTabs = function ({ tabs, linkShallow, linkScroll, actions, ...props }: NavTabProps) {
return (
<div className="mb-4 h-9 max-w-[calc(100%+40px)] lg:mb-5">
<div className="mb-4 h-9 max-w-full lg:mb-5">
<nav
className="no-scrollbar flex max-h-9 space-x-1 overflow-scroll rounded-md"
aria-label="Tabs"

View File

@ -36,7 +36,10 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
({ className, ...props }, ref) => (
<tr
ref={ref}
className={classNames("hover:bg-subtle data-[state=selected]:bg-subtle border-b", className)}
className={classNames(
"hover:bg-subtle data-[state=selected]:bg-subtle border-subtle border-b",
className
)}
{...props}
/>
)

View File

@ -4026,6 +4026,12 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/debugging@workspace:packages/debugging":
version: 0.0.0-use.local
resolution: "@calcom/debugging@workspace:packages/debugging"
languageName: unknown
linkType: soft
"@calcom/discord@workspace:packages/app-store/discord":
version: 0.0.0-use.local
resolution: "@calcom/discord@workspace:packages/app-store/discord"