Feature/parallel booking availability (#3087)

pull/3106/head^2
Omar López 2022-06-19 09:02:00 -06:00 committed by GitHub
parent 7599f2384e
commit 2d28ca61a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 242 additions and 121 deletions

View File

@ -4,7 +4,7 @@ import { useEffect } from "react";
import { trpc } from "@lib/trpc"; import { trpc } from "@lib/trpc";
export function useViewerI18n() { export function useViewerI18n() {
return trpc.useQuery(["viewer.i18n"], { return trpc.useQuery(["viewer.public.i18n"], {
staleTime: Infinity, staleTime: Infinity,
}); });
} }

View File

@ -21,7 +21,7 @@ export default function SAMLLogin(props: Props) {
const methods = useFormContext(); const methods = useFormContext();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const mutation = trpc.useMutation("viewer.samlTenantProduct", { const mutation = trpc.useMutation("viewer.public.samlTenantProduct", {
onSuccess: async (data) => { onSuccess: async (data) => {
await signIn("saml", {}, { tenant: data.tenant, product: data.product }); await signIn("saml", {}, { tenant: data.tenant, product: data.product });
}, },

View File

@ -36,7 +36,7 @@ import { yyyymmdd } from "@calcom/lib/date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getRecurringFreq } from "@calcom/lib/recurringStrings"; import { getRecurringFreq } from "@calcom/lib/recurringStrings";
import { localStorage } from "@calcom/lib/webstorage"; import { localStorage } from "@calcom/lib/webstorage";
import DatePicker from "@calcom/ui/booker/DatePicker"; import DatePicker, { Day } from "@calcom/ui/booker/DatePicker";
import { asStringOrNull } from "@lib/asStringOrNull"; import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock"; import { timeZone } from "@lib/clock";
@ -54,8 +54,6 @@ import { HeadSeo } from "@components/seo/head-seo";
import AvatarGroup from "@components/ui/AvatarGroup"; import AvatarGroup from "@components/ui/AvatarGroup";
import PoweredByCal from "@components/ui/PoweredByCal"; import PoweredByCal from "@components/ui/PoweredByCal";
import type { Slot } from "@server/routers/viewer/slots";
import type { AvailabilityPageProps } from "../../../pages/[user]/[type]"; import type { AvailabilityPageProps } from "../../../pages/[user]/[type]";
import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]"; import type { DynamicAvailabilityPageProps } from "../../../pages/d/[link]/[slug]";
import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]"; import type { AvailabilityTeamPageProps } from "../../../pages/team/[slug]/[type]";
@ -123,14 +121,18 @@ const useSlots = ({
startTime: Date; startTime: Date;
endTime: Date; endTime: Date;
}) => { }) => {
const { data, isLoading } = trpc.useQuery([ const { data, isLoading } = trpc.useQuery(
"viewer.slots.getSchedule", [
{ "viewer.public.slots.getSchedule",
eventTypeId, {
startTime: startTime.toISOString(), eventTypeId,
endTime: endTime.toISOString(), startTime: startTime.toISOString(),
}, endTime: endTime.toISOString(),
]); },
],
/** Prevents fetching past dates */
{ enabled: dayjs(startTime).isAfter(dayjs().subtract(1, "day")) }
);
return { slots: data?.slots || {}, isLoading }; return { slots: data?.slots || {}, isLoading };
}; };
@ -165,18 +167,10 @@ const SlotPicker = ({
const { slots, isLoading } = useSlots({ const { slots, isLoading } = useSlots({
eventTypeId: eventType.id, eventTypeId: eventType.id,
startTime: startDate, startTime: dayjs(startDate).startOf("day").toDate(),
endTime: dayjs(startDate).endOf("month").toDate(), endTime: dayjs(startDate).endOf("month").toDate(),
}); });
const [times, setTimes] = useState<Slot[]>([]);
useEffect(() => {
if (selectedDate && slots[yyyymmdd(selectedDate)]) {
setTimes(slots[yyyymmdd(selectedDate)]);
}
}, [selectedDate, slots]);
return ( return (
<> <>
<DatePicker <DatePicker
@ -187,19 +181,27 @@ const SlotPicker = ({
? "sm:w-1/2 sm:border-r sm:pl-4 sm:pr-6 sm:dark:border-gray-700 md:w-1/3 " ? "sm:w-1/2 sm:border-r sm:pl-4 sm:pr-6 sm:dark:border-gray-700 md:w-1/3 "
: "sm:pl-4") : "sm:pl-4")
} }
locale={isLocaleReady ? i18n.language : "en"}
includedDates={Object.keys(slots).filter((k) => slots[k].length > 0)} includedDates={Object.keys(slots).filter((k) => slots[k].length > 0)}
locale={isLocaleReady ? i18n.language : "en"}
selected={selectedDate} selected={selectedDate}
onChange={setSelectedDate} onChange={setSelectedDate}
onMonthChange={setStartDate} onMonthChange={(startDate) => {
// set the minimum day to today in the current month, not the beginning of the month
setStartDate(
dayjs(startDate).isBefore(dayjs().subtract(1, "day"))
? dayjs(new Date()).startOf("day").toDate()
: startDate
);
}}
weekStart={weekStart} weekStart={weekStart}
// DayComponent={(props) => <DayContainer {...props} eventTypeId={eventType.id} />}
/> />
<div className="mt-4 ml-1 block sm:hidden">{timezoneDropdown}</div> <div className="mt-4 ml-1 block sm:hidden">{timezoneDropdown}</div>
{selectedDate && ( {selectedDate && (
<AvailableTimes <AvailableTimes
slots={times} slots={slots[yyyymmdd(selectedDate)]}
date={dayjs(selectedDate)} date={dayjs(selectedDate)}
timeFormat={timeFormat} timeFormat={timeFormat}
eventTypeId={eventType.id} eventTypeId={eventType.id}
@ -261,6 +263,9 @@ const useDateSelected = ({ timeZone }: { timeZone?: string }) => {
const [selectedDate, _setSelectedDate] = useState<Date>(); const [selectedDate, _setSelectedDate] = useState<Date>();
useEffect(() => { useEffect(() => {
/** TODO: router.query.date is comming as `null` even when set like this:
* `/user/type?date=2022-06-22-0600`
*/
const dateString = asStringOrNull(router.query.date); const dateString = asStringOrNull(router.query.date);
if (dateString) { if (dateString) {
const offsetString = dateString.substr(11, 14); // hhmm const offsetString = dateString.substr(11, 14); // hhmm
@ -275,6 +280,7 @@ const useDateSelected = ({ timeZone }: { timeZone?: string }) => {
(offsetMinute !== "" ? parseInt(offsetMinute) : 0)); (offsetMinute !== "" ? parseInt(offsetMinute) : 0));
const date = dayjs(dateString.substr(0, 10)).utcOffset(utcOffsetInMinutes, true); const date = dayjs(dateString.substr(0, 10)).utcOffset(utcOffsetInMinutes, true);
console.log("date.isValid()", date.isValid());
if (date.isValid()) { if (date.isValid()) {
setSelectedDate(date.toDate()); setSelectedDate(date.toDate());
} }
@ -674,4 +680,29 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
); );
}; };
const DayContainer = (props: React.ComponentProps<typeof Day> & { eventTypeId: number }) => {
const { eventTypeId, ...rest } = props;
/** :
* Fetch each individual day here. All these are batched with tRPC anyways.
**/
const { slots } = useSlots({
eventTypeId,
startTime: dayjs(props.date).startOf("day").toDate(),
endTime: dayjs(props.date).endOf("day").toDate(),
});
const includedDates = Object.keys(slots).filter((k) => slots[k].length > 0);
const disabled = includedDates.length > 0 ? !includedDates.includes(yyyymmdd(props.date)) : props.disabled;
return <Day {...{ ...rest, disabled }} />;
};
const AvailableTimesContainer = (props: React.ComponentProps<typeof AvailableTimes>) => {
const { date, eventTypeId } = props;
const { slots } = useSlots({
eventTypeId,
startTime: dayjs(date).startOf("day").toDate(),
endTime: dayjs(date).endOf("day").toDate(),
});
return <AvailableTimes {...props} slots={slots[date.format("YYYY-MM-DD")]} />;
};
export default AvailabilityPage; export default AvailabilityPage;

View File

@ -17,7 +17,7 @@ const DisableUserImpersonation = ({ disableImpersonation }: { disableImpersonati
await utils.invalidateQueries(["viewer.me"]); await utils.invalidateQueries(["viewer.me"]);
}, },
async onSettled() { async onSettled() {
await utils.invalidateQueries(["viewer.i18n"]); await utils.invalidateQueries(["viewer.public.i18n"]);
}, },
}); });

View File

@ -101,7 +101,13 @@ type TError = TRPCClientErrorLike<AppRouter>;
const withQuery = <TPath extends keyof TQueryValues & string>( const withQuery = <TPath extends keyof TQueryValues & string>(
pathAndInput: [path: TPath, ...args: inferHandlerInput<TQueries[TPath]>], pathAndInput: [path: TPath, ...args: inferHandlerInput<TQueries[TPath]>],
params?: UseTRPCQueryOptions<TPath, TQueryValues[TPath]["input"], TQueryValues[TPath]["output"], TError> params?: UseTRPCQueryOptions<
TPath,
TQueryValues[TPath]["input"],
TQueryValues[TPath]["output"],
TQueryValues[TPath]["output"],
TError
>
) => { ) => {
return function WithQuery( return function WithQuery(
opts: Omit< opts: Omit<

View File

@ -26,7 +26,7 @@ type AppPropsWithChildren = AppProps & {
}; };
const CustomI18nextProvider = (props: AppPropsWithChildren) => { const CustomI18nextProvider = (props: AppPropsWithChildren) => {
const { i18n, locale } = trpc.useQuery(["viewer.i18n"]).data ?? { const { i18n, locale } = trpc.useQuery(["viewer.public.i18n"]).data ?? {
locale: "en", locale: "en",
}; };
@ -42,7 +42,7 @@ const CustomI18nextProvider = (props: AppPropsWithChildren) => {
}; };
const AppProviders = (props: AppPropsWithChildren) => { const AppProviders = (props: AppPropsWithChildren) => {
const session = trpc.useQuery(["viewer.session"]).data; const session = trpc.useQuery(["viewer.public.session"]).data;
// No need to have intercom on public pages - Good for Page Performance // No need to have intercom on public pages - Good for Page Performance
const isPublicPage = usePublicPage(); const isPublicPage = usePublicPage();
const RemainingProviders = ( const RemainingProviders = (

View File

@ -1,5 +1,8 @@
import { EventType, PeriodType } from "@prisma/client"; import { EventType, PeriodType } from "@prisma/client";
import dayjs from "dayjs"; import dayjs from "dayjs";
import dayjsBusinessTime from "dayjs-business-days2";
dayjs.extend(dayjsBusinessTime);
function isOutOfBounds( function isOutOfBounds(
time: dayjs.ConfigType, time: dayjs.ConfigType,

View File

@ -58,10 +58,10 @@
"@radix-ui/react-tooltip": "^0.1.0", "@radix-ui/react-tooltip": "^0.1.0",
"@stripe/react-stripe-js": "^1.8.0", "@stripe/react-stripe-js": "^1.8.0",
"@stripe/stripe-js": "^1.29.0", "@stripe/stripe-js": "^1.29.0",
"@trpc/client": "^9.23.4", "@trpc/client": "^9.25.2",
"@trpc/next": "^9.23.4", "@trpc/next": "^9.25.2",
"@trpc/react": "^9.23.4", "@trpc/react": "^9.25.2",
"@trpc/server": "^9.23.4", "@trpc/server": "^9.25.2",
"@vercel/edge-functions-ui": "^0.2.1", "@vercel/edge-functions-ui": "^0.2.1",
"@wojtekmaj/react-daterange-picker": "^3.3.1", "@wojtekmaj/react-daterange-picker": "^3.3.1",
"accept-language-parser": "^1.5.0", "accept-language-parser": "^1.5.0",

View File

@ -1,4 +1,5 @@
import { UserPlan } from "@prisma/client"; import { UserPlan } from "@prisma/client";
import dayjs from "dayjs";
import { GetStaticPropsContext } from "next"; import { GetStaticPropsContext } from "next";
import { JSONObject } from "superjson/dist/types"; import { JSONObject } from "superjson/dist/types";
import { z } from "zod"; import { z } from "zod";
@ -54,7 +55,10 @@ export default function Type(props: AvailabilityPageProps) {
); );
} }
async function getUserPageProps({ username, slug }: { username: string; slug: string }) { async function getUserPageProps(context: GetStaticPropsContext) {
const { type: slug, user: username } = paramsSchema.parse(context.params);
const { ssgInit } = await import("@server/lib/ssg");
const ssg = await ssgInit(context);
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
username, username,
@ -150,6 +154,13 @@ async function getUserPageProps({ username, slug }: { username: string; slug: st
const profile = eventType.users[0] || user; const profile = eventType.users[0] || user;
const startTime = new Date();
await ssg.fetchQuery("viewer.public.slots.getSchedule", {
eventTypeId: eventType.id,
startTime: dayjs(startTime).startOf("day").toISOString(),
endTime: dayjs(startTime).endOf("day").toISOString(),
});
return { return {
props: { props: {
eventType: eventTypeObject, eventType: eventTypeObject,
@ -168,18 +179,18 @@ async function getUserPageProps({ username, slug }: { username: string; slug: st
}, },
away: user?.away, away: user?.away,
isDynamic: false, isDynamic: false,
trpcState: ssg.dehydrate(),
}, },
revalidate: 10, // seconds revalidate: 10, // seconds
}; };
} }
async function getDynamicGroupPageProps({ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
usernameList, const { ssgInit } = await import("@server/lib/ssg");
length, const ssg = await ssgInit(context);
}: { const { type: typeParam, user: userParam } = paramsSchema.parse(context.params);
usernameList: string[]; const usernameList = getUsernameList(userParam);
length: number; const length = parseInt(typeParam);
}) {
const eventType = getDefaultEvent("" + length); const eventType = getDefaultEvent("" + length);
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
@ -264,6 +275,7 @@ async function getDynamicGroupPageProps({
profile, profile,
isDynamic: true, isDynamic: true,
away: false, away: false,
trpcState: ssg.dehydrate(),
}, },
revalidate: 10, // seconds revalidate: 10, // seconds
}; };
@ -272,17 +284,13 @@ async function getDynamicGroupPageProps({
const paramsSchema = z.object({ type: z.string(), user: z.string() }); const paramsSchema = z.object({ type: z.string(), user: z.string() });
export const getStaticProps = async (context: GetStaticPropsContext) => { export const getStaticProps = async (context: GetStaticPropsContext) => {
const { type: typeParam, user: userParam } = paramsSchema.parse(context.params); const { user: userParam } = paramsSchema.parse(context.params);
// dynamic groups are not generated at build time, but otherwise are probably cached until infinity. // dynamic groups are not generated at build time, but otherwise are probably cached until infinity.
const isDynamicGroup = userParam.includes("+"); const isDynamicGroup = userParam.includes("+");
if (isDynamicGroup) { if (isDynamicGroup) {
return await getDynamicGroupPageProps({ return await getDynamicGroupPageProps(context);
usernameList: getUsernameList(userParam),
length: parseInt(typeParam),
});
} else { } else {
return await getUserPageProps({ username: userParam, slug: typeParam }); return await getUserPageProps(context);
} }
}; };

View File

@ -13,7 +13,9 @@ import I18nLanguageHandler from "@components/I18nLanguageHandler";
import type { AppRouter } from "@server/routers/_app"; import type { AppRouter } from "@server/routers/_app";
import { httpBatchLink } from "@trpc/client/links/httpBatchLink"; import { httpBatchLink } from "@trpc/client/links/httpBatchLink";
import { httpLink } from "@trpc/client/links/httpLink";
import { loggerLink } from "@trpc/client/links/loggerLink"; import { loggerLink } from "@trpc/client/links/loggerLink";
import { splitLink } from "@trpc/client/links/splitLink";
import { withTRPC } from "@trpc/next"; import { withTRPC } from "@trpc/next";
import type { TRPCClientErrorLike } from "@trpc/react"; import type { TRPCClientErrorLike } from "@trpc/react";
import { Maybe } from "@trpc/server"; import { Maybe } from "@trpc/server";
@ -56,6 +58,13 @@ function MyApp(props: AppProps) {
export default withTRPC<AppRouter>({ export default withTRPC<AppRouter>({
config() { config() {
const url =
typeof window !== "undefined"
? "/api/trpc"
: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}/api/trpc`
: `http://${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/trpc`;
/** /**
* If you want to use SSR, you need to use the server's full URL * If you want to use SSR, you need to use the server's full URL
* @link https://trpc.io/docs/ssr * @link https://trpc.io/docs/ssr
@ -70,8 +79,17 @@ export default withTRPC<AppRouter>({
enabled: (opts) => enabled: (opts) =>
!!process.env.NEXT_PUBLIC_DEBUG || (opts.direction === "down" && opts.result instanceof Error), !!process.env.NEXT_PUBLIC_DEBUG || (opts.direction === "down" && opts.result instanceof Error),
}), }),
httpBatchLink({ splitLink({
url: `/api/trpc`, // check for context property `skipBatch`
condition: (op) => op.context.skipBatch === true,
// when condition is true, use normal request
true: httpLink({ url }),
// when condition is false, use batching
false: httpBatchLink({
url,
/** @link https://github.com/trpc/trpc/issues/2008 */
// maxBatchSize: 7
}),
}), }),
], ],
/** /**

View File

@ -1,10 +1,10 @@
import { import {
BookingStatus,
User,
Booking,
Attendee, Attendee,
Booking,
BookingReference, BookingReference,
BookingStatus,
EventType, EventType,
User,
WebhookTriggerEvents, WebhookTriggerEvents,
} from "@prisma/client"; } from "@prisma/client";
import dayjs from "dayjs"; import dayjs from "dayjs";
@ -13,7 +13,7 @@ import { getSession } from "next-auth/react";
import type { TFunction } from "next-i18next"; import type { TFunction } from "next-i18next";
import { z, ZodError } from "zod"; import { z, ZodError } from "zod";
import { getCalendar } from "@calcom/core/CalendarManager"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder"; import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder";
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director"; import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
import { deleteMeeting } from "@calcom/core/videoClient"; import { deleteMeeting } from "@calcom/core/videoClient";

View File

@ -3,8 +3,8 @@ import async from "async";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter"; import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { getCalendar } from "@calcom/core/CalendarManager";
import { deleteMeeting } from "@calcom/core/videoClient"; import { deleteMeeting } from "@calcom/core/videoClient";
import { sendCancelledEmails } from "@calcom/emails"; import { sendCancelledEmails } from "@calcom/emails";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";

View File

@ -29,7 +29,23 @@ export default trpcNext.createNextApiHandler({
/** /**
* @link https://trpc.io/docs/caching#api-response-caching * @link https://trpc.io/docs/caching#api-response-caching
*/ */
// responseMeta() { responseMeta({ ctx, paths, type, errors }) {
// // ... // assuming we have all our public routes in `viewer.public`
// }, const allPublic = paths && paths.every((path) => path.startsWith("viewer.public."));
// checking that no procedures errored
const allOk = errors.length === 0;
// checking we're doing a query request
const isQuery = type === "query";
if (allPublic && allOk && isQuery) {
// cache request for 1 day + revalidate once every 5 seconds
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
return {
headers: {
"cache-control": `s-maxage=5, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`,
},
};
}
return {};
},
}); });

View File

@ -84,7 +84,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" }); document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
}, },
async onSettled() { async onSettled() {
await utils.invalidateQueries(["viewer.i18n"]); await utils.invalidateQueries(["viewer.public.i18n"]);
}, },
}); });
@ -481,7 +481,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
); );
} }
const WithQuery = withQuery(["viewer.i18n"]); const WithQuery = withQuery(["viewer.public.i18n"]);
export default function Settings(props: Props) { export default function Settings(props: Props) {
const { t } = useLocale(); const { t } = useLocale();

View File

@ -37,7 +37,7 @@ export async function ssgInit<TParams extends { locale?: string }>(opts: GetStat
}); });
// always preload i18n // always preload i18n
await ssg.fetchQuery("viewer.i18n"); await ssg.fetchQuery("viewer.public.i18n");
return ssg; return ssg;
} }

View File

@ -21,8 +21,8 @@ export async function ssrInit(context: GetServerSidePropsContext) {
ctx, ctx,
}); });
// always preload "viewer.i18n" // always preload "viewer.public.i18n"
await ssr.fetchQuery("viewer.i18n"); await ssr.fetchQuery("viewer.public.i18n");
return ssr; return ssr;
} }

View File

@ -68,7 +68,8 @@ const publicViewerRouter = createRouter()
return await samlTenantProduct(prisma, email); return await samlTenantProduct(prisma, email);
}, },
}); })
.merge("slots.", slotsRouter);
// routes only available to authenticated users // routes only available to authenticated users
const loggedInViewerRouter = createProtectedRouter() const loggedInViewerRouter = createProtectedRouter()
@ -944,12 +945,11 @@ const loggedInViewerRouter = createProtectedRouter()
}); });
export const viewerRouter = createRouter() export const viewerRouter = createRouter()
.merge(publicViewerRouter) .merge("public.", publicViewerRouter)
.merge(loggedInViewerRouter) .merge(loggedInViewerRouter)
.merge("bookings.", bookingsRouter) .merge("bookings.", bookingsRouter)
.merge("eventTypes.", eventTypesRouter) .merge("eventTypes.", eventTypesRouter)
.merge("availability.", availabilityRouter) .merge("availability.", availabilityRouter)
.merge("teams.", viewerTeamsRouter) .merge("teams.", viewerTeamsRouter)
.merge("webhook.", webhookRouter) .merge("webhook.", webhookRouter)
.merge("apiKeys.", apiKeysRouter) .merge("apiKeys.", apiKeysRouter);
.merge("slots.", slotsRouter);

View File

@ -9,6 +9,7 @@ import { availabilityUserSelect } from "@calcom/prisma";
import { stringToDayjs } from "@calcom/prisma/zod-utils"; import { stringToDayjs } from "@calcom/prisma/zod-utils";
import { TimeRange, WorkingHours } from "@calcom/types/schedule"; import { TimeRange, WorkingHours } from "@calcom/types/schedule";
import isOutOfBounds from "@lib/isOutOfBounds";
import getSlots from "@lib/slots"; import getSlots from "@lib/slots";
import { createRouter } from "@server/createRouter"; import { createRouter } from "@server/createRouter";
@ -137,6 +138,11 @@ export const slotsRouter = createRouter().query("getSchedule", {
beforeEventBuffer: true, beforeEventBuffer: true,
afterEventBuffer: true, afterEventBuffer: true,
schedulingType: true, schedulingType: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodCountCalendarDays: true,
periodDays: true,
schedule: { schedule: {
select: { select: {
availability: true, availability: true,
@ -202,6 +208,14 @@ export const slotsRouter = createRouter().query("getSchedule", {
afterBufferTime: eventType.afterEventBuffer, afterBufferTime: eventType.afterEventBuffer,
currentSeats, currentSeats,
}; };
const isWithinBounds = (_time: Parameters<typeof isOutOfBounds>[0]) =>
!isOutOfBounds(_time, {
periodType: eventType.periodType,
periodStartDate: eventType.periodStartDate,
periodEndDate: eventType.periodEndDate,
periodCountCalendarDays: eventType.periodCountCalendarDays,
periodDays: eventType.periodDays,
});
let time = dayjs(startTime); let time = dayjs(startTime);
do { do {
@ -215,18 +229,17 @@ export const slotsRouter = createRouter().query("getSchedule", {
}); });
// if ROUND_ROBIN - slots stay available on some() - if normal / COLLECTIVE - slots only stay available on every() // if ROUND_ROBIN - slots stay available on some() - if normal / COLLECTIVE - slots only stay available on every()
const filteredTimes = const filterStrategy =
!eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE
? times.filter((time) => ? ("every" as const)
userSchedules.every((schedule) => : ("some" as const);
checkForAvailability({ time, ...schedule, ...availabilityCheckProps }) const filteredTimes = times
) .filter(isWithinBounds)
) .filter((time) =>
: times.filter((time) => userSchedules[filterStrategy]((schedule) =>
userSchedules.some((schedule) => checkForAvailability({ time, ...schedule, ...availabilityCheckProps })
checkForAvailability({ time, ...schedule, ...availabilityCheckProps }) )
) );
);
slots[yyyymmdd(time.toDate())] = filteredTimes.map((time) => ({ slots[yyyymmdd(time.toDate())] = filteredTimes.map((time) => ({
time: time.toISOString(), time: time.toISOString(),

View File

@ -1,21 +1,18 @@
import { Credential, SelectedCalendar } from "@prisma/client"; import { Credential, SelectedCalendar } from "@prisma/client";
import { createHash } from "crypto";
import _ from "lodash"; import _ from "lodash";
import cache from "memory-cache";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import getApps from "@calcom/app-store/utils"; import getApps from "@calcom/app-store/utils";
import { getUid } from "@calcom/lib/CalEventParser"; import { getUid } from "@calcom/lib/CalEventParser";
import { getErrorFromUnknown } from "@calcom/lib/errors"; import { getErrorFromUnknown } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import notEmpty from "@calcom/lib/notEmpty";
import type { CalendarEvent, EventBusyDate, NewCalendarEventType } from "@calcom/types/Calendar"; import type { CalendarEvent, EventBusyDate, NewCalendarEventType } from "@calcom/types/Calendar";
import type { Event } from "@calcom/types/Event";
import type { EventResult } from "@calcom/types/EventManager"; import type { EventResult } from "@calcom/types/EventManager";
const log = logger.getChildLogger({ prefix: ["CalendarManager"] }); const log = logger.getChildLogger({ prefix: ["CalendarManager"] });
/** TODO: Remove once all references are updated to app-store */
export { getCalendar };
export const getCalendarCredentials = (credentials: Array<Credential>, userId: number) => { export const getCalendarCredentials = (credentials: Array<Credential>, userId: number) => {
const calendarCredentials = getApps(credentials) const calendarCredentials = getApps(credentials)
.filter((app) => app.type.endsWith("_calendar")) .filter((app) => app.type.endsWith("_calendar"))
@ -77,20 +74,51 @@ export const getConnectedCalendars = async (
return connectedCalendars; return connectedCalendars;
}; };
const CACHING_TIME = 30_000; // 30 seconds
const getCachedResults = (
withCredentials: Credential[],
dateFrom: string,
dateTo: string,
selectedCalendars: SelectedCalendar[]
) => {
const calendarCredentials = withCredentials.filter((credential) => credential.type.endsWith("_calendar"));
const calendars = calendarCredentials.map((credential) => getCalendar(credential));
const results = calendars.map(async (c, i) => {
/** Filter out nulls */
if (!c) return [];
/** We rely on the index so we can match credentials with calendars */
const { id, type } = calendarCredentials[i];
/** We just pass the calendars that matched the credential type,
* TODO: Migrate credential type or appId
*/
const passedSelectedCalendars = selectedCalendars.filter((sc) => sc.integration === type);
/** We extract external Ids so we don't cache too much */
const selectedCalendarIds = passedSelectedCalendars.map((sc) => sc.externalId);
/** We create a unque hash key based on the input data */
const cacheKey = createHash("md5").update(JSON.stringify({ id, selectedCalendarIds })).digest("hex");
/** Check if we already have cached data and return */
const cachedAvailability = cache.get(cacheKey);
if (cachedAvailability) return cachedAvailability;
/** If we don't then we actually fetch external calendars (which can be very slow) */
const availability = await c.getAvailability(dateFrom, dateTo, passedSelectedCalendars);
/** We save the availability to a few seconds so recurrent calls are nearly instant */
cache.put(cacheKey, availability, CACHING_TIME);
return availability;
});
return Promise.all(results);
};
export const getBusyCalendarTimes = async ( export const getBusyCalendarTimes = async (
withCredentials: Credential[], withCredentials: Credential[],
dateFrom: string, dateFrom: string,
dateTo: string, dateTo: string,
selectedCalendars: SelectedCalendar[] selectedCalendars: SelectedCalendar[]
) => { ) => {
const calendars = withCredentials
.filter((credential) => credential.type.endsWith("_calendar"))
.map((credential) => getCalendar(credential))
.filter(notEmpty);
let results: EventBusyDate[][] = []; let results: EventBusyDate[][] = [];
try { try {
results = await Promise.all(calendars.map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars))); results = await getCachedResults(withCredentials, dateFrom, dateTo, selectedCalendars);
} catch (error) { } catch (error) {
log.warn(error); log.warn(error);
} }

View File

@ -34,19 +34,15 @@ export type DatePickerProps = {
isLoading?: boolean; isLoading?: boolean;
}; };
const Day = ({ export const Day = ({
date, date,
active, active,
...props ...props
}: JSX.IntrinsicElements["button"] & { active: boolean; date: Date }) => { }: JSX.IntrinsicElements["button"] & { active: boolean; date: Date }) => {
return ( return (
<button <button
style={props.disabled ? {} : {}}
className={classNames( className={classNames(
"absolute top-0 left-0 right-0 bottom-0 mx-auto w-full rounded-sm border border-transparent text-center", "hover:border-brand disabled:text-bookinglighter absolute top-0 left-0 right-0 bottom-0 mx-auto w-full rounded-sm border border-transparent text-center font-medium disabled:cursor-default disabled:border-transparent disabled:font-light dark:hover:border-white disabled:dark:border-transparent",
props.disabled
? "text-bookinglighter cursor-default font-light"
: "hover:border-brand font-medium dark:hover:border-white",
active active
? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast" ? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast"
: !props.disabled : !props.disabled
@ -68,9 +64,11 @@ const Days = ({
includedDates = [], includedDates = [],
browsingDate, browsingDate,
weekStart, weekStart,
DayComponent = Day,
selected, selected,
...props ...props
}: Omit<DatePickerProps, "locale" | "className" | "weekStart"> & { }: Omit<DatePickerProps, "locale" | "className" | "weekStart"> & {
DayComponent?: React.FC<React.ComponentProps<typeof Day>>;
browsingDate: Date; browsingDate: Date;
weekStart: number; weekStart: number;
}) => { }) => {
@ -90,16 +88,11 @@ const Days = ({
return ( return (
<> <>
{days.map((day, idx) => ( {days.map((day, idx) => (
<div <div key={day === null ? `e-${idx}` : `day-${day}`} className="relative w-full pt-[100%]">
key={day === null ? `e-${idx}` : `day-${day}`}
style={{
paddingTop: "100%",
}}
className="relative w-full">
{day === null ? ( {day === null ? (
<div key={`e-${idx}`} /> <div key={`e-${idx}`} />
) : ( ) : (
<Day <DayComponent
date={day} date={day}
onClick={() => props.onChange(day)} onClick={() => props.onChange(day)}
disabled={ disabled={
@ -139,7 +132,7 @@ const DatePicker = ({
onMonthChange, onMonthChange,
isLoading = false, isLoading = false,
...passThroughProps ...passThroughProps
}: DatePickerProps) => { }: DatePickerProps & Partial<React.ComponentProps<typeof Days>>) => {
const [month, setMonth] = useState(selected ? selected.getMonth() : new Date().getMonth()); const [month, setMonth] = useState(selected ? selected.getMonth() : new Date().getMonth());
const changeMonth = (newMonth: number) => { const changeMonth = (newMonth: number) => {

View File

@ -3140,32 +3140,32 @@
javascript-natural-sort "0.7.1" javascript-natural-sort "0.7.1"
lodash "4.17.21" lodash "4.17.21"
"@trpc/client@^9.23.4": "@trpc/client@^9.25.2":
version "9.23.4" version "9.25.2"
resolved "https://registry.yarnpkg.com/@trpc/client/-/client-9.23.4.tgz#d887d013ca9146299df1a33a1f13d57366a3483f" resolved "https://registry.yarnpkg.com/@trpc/client/-/client-9.25.2.tgz#3ece63bc58436a12a49a7ef3aa3ff54f03bdd750"
integrity sha512-usZgbydmqqzUqJQD5yY9f+KftYBKz6ed0J90MYETqRwXl9XtR9kKOwRRMBQwZDdeMhai3Hlk4sDWeC9HR3auRQ== integrity sha512-wW4KEvG+mF5ParqmyTgSjxwOjvxtsQCso6nZeZ3PI19d9bBUprFx9E+UkS5R3gqNm9lddQx7tx+ie+OFkDmp3g==
dependencies: dependencies:
"@babel/runtime" "^7.9.0" "@babel/runtime" "^7.9.0"
"@trpc/next@^9.23.4": "@trpc/next@^9.25.2":
version "9.23.4" version "9.25.2"
resolved "https://registry.yarnpkg.com/@trpc/next/-/next-9.23.4.tgz#45c4da34cd99b882aefa0ddc065dd23c5d5467a2" resolved "https://registry.yarnpkg.com/@trpc/next/-/next-9.25.2.tgz#721182d1426e28848c87ef473dff781e9c812ab4"
integrity sha512-fPFU/PNkGawiUS56wRx3KsnRh/cvI3RtGYbZKIxLr38oYyHLct/v1uqA+0xJIoyPlcFDLoB6et4MZJuv3/v8Dg== integrity sha512-OiRcmNd5sxTR23O2oVxBVoSj4DNVZe84GzXBKxShYjeDyld0OKmQzqB1H2rMsCmNU7/Yd4lYDQBjGnDVP5VxPQ==
dependencies: dependencies:
"@babel/runtime" "^7.9.0" "@babel/runtime" "^7.9.0"
react-ssr-prepass "^1.5.0" react-ssr-prepass "^1.5.0"
"@trpc/react@^9.23.4": "@trpc/react@^9.25.2":
version "9.23.4" version "9.25.2"
resolved "https://registry.yarnpkg.com/@trpc/react/-/react-9.23.4.tgz#6abb2bbabd76d72cc1a3fb72f6ecc33702cb1c3d" resolved "https://registry.yarnpkg.com/@trpc/react/-/react-9.25.2.tgz#93c9c9c46bfee16ede9b326c2c964a38b4f8b84d"
integrity sha512-adori41F2hgjijThOmoeMkIXcaRjScZi6lczRwIIy6UqoUjYb+FfO46Jr7cP/QSX1Ur8VatD6UgLCNViURTfCQ== integrity sha512-osP1JmhhBLWmSkoCc19YcGiQdeEq0RtvO0uvLe2DfzGpQL1orB72nvqWHXlIGHWA4E0cnc1FRmoXokWYCwHPxQ==
dependencies: dependencies:
"@babel/runtime" "^7.9.0" "@babel/runtime" "^7.9.0"
"@trpc/server@^9.23.4": "@trpc/server@^9.25.2":
version "9.23.4" version "9.25.2"
resolved "https://registry.yarnpkg.com/@trpc/server/-/server-9.23.4.tgz#78ecebbdda79db6252067d66c4a0edef025a501b" resolved "https://registry.yarnpkg.com/@trpc/server/-/server-9.25.2.tgz#3ac58753b4cf7b2aa1a2a51d94bd0798b495a78a"
integrity sha512-nlOgft5g4BziNplDHhw7f4m9+k8lRPFtVVi8VGxOINt7sQ8pzzEti0WMIl2BX6gugw92t+kae2O5e793AFJs9g== integrity sha512-E5ibK5jLgWremiPs2pO+Y/YktRH7+CqmMwp97mTp9ymYZn3od4C9TuFg6bxEK1bQKnUezpzHJyGRADVKCWrjsw==
"@tryvital/vital-node@^1.3.6": "@tryvital/vital-node@^1.3.6":
version "1.3.6" version "1.3.6"
@ -3560,11 +3560,16 @@
resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197"
integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
"@types/node@*", "@types/node@16.9.1", "@types/node@>=12.0.0", "@types/node@>=8.1.0", "@types/node@^12.12.6": "@types/node@*", "@types/node@16.9.1", "@types/node@>=12.0.0", "@types/node@>=8.1.0":
version "16.9.1" version "16.9.1"
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.1.tgz#0611b37db4246c937feef529ddcc018cf8e35708" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.1.tgz#0611b37db4246c937feef529ddcc018cf8e35708"
integrity sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g== integrity sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==
"@types/node@^12.12.6":
version "12.20.55"
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240"
integrity sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==
"@types/nodemailer@^6.4.4": "@types/nodemailer@^6.4.4":
version "6.4.4" version "6.4.4"
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.4.tgz#c265f7e7a51df587597b3a49a023acaf0c741f4b" resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.4.tgz#c265f7e7a51df587597b3a49a023acaf0c741f4b"
@ -17625,4 +17630,4 @@ zwitch@^1.0.0:
zwitch@^2.0.0: zwitch@^2.0.0:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.2.tgz#91f8d0e901ffa3d66599756dde7f57b17c95dce1" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.2.tgz#91f8d0e901ffa3d66599756dde7f57b17c95dce1"
integrity sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA== integrity sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA==