perf: caching + modularize data fetching for team booking pages (#22172)

* wip

* wip

* wip

* refactor

* refactor

* refactor

* refactor

* refactor

* fix

* better

* fix

* fix

* use notFound

* refactor

* wip

* refactor

* wip

* refactor

* wip

* wip

* finalize

* wip

* fix

* lots of refactors

* better code

* clean up

* fix

* fix type checks

* finalize

* select more fields

* select more fields

* fix

* fix

* fix

* fix

* better

* better

* fix

* fix

* fix

* fix

* add back comment

* fix

* better

* fix

* refactor

* fix

* fix type check

* add comment

* fix

* wip

* wip

* add more comment

* refactor

* rename

* refactors

* fix reschedule logic

* remove hard-coded true from isCachedEnabled

* better

* use revalidateTeamDataCache in team profile update

* fix invalidation

* better comment

* refactors

* add better comments

* add revalidations

* add comments

* fix

* remove hard code

* fix

* update tags

* use features package

* fix type check
This commit is contained in:
Benny Joo
2025-07-15 21:42:55 -03:00
committed by GitHub
parent e6da5c85e0
commit c4eda5f21d
19 changed files with 932 additions and 5 deletions
@@ -0,0 +1,27 @@
"use server";
import { revalidateTag } from "next/cache";
// Coupled with `getCachedTeamData` in `queries.ts`
export async function revalidateTeamDataCache({
teamSlug,
orgSlug,
}: {
teamSlug: string;
orgSlug: string | null;
}) {
revalidateTag(`team:${orgSlug ? `${orgSlug}:` : ""}${teamSlug}`);
}
// Coupled with `getCachedTeamEventType` in `queries.ts`
export async function revalidateTeamEventTypeCache({
teamSlug,
meetingSlug,
orgSlug,
}: {
teamSlug: string;
meetingSlug: string;
orgSlug: string | null;
}) {
revalidateTag(`event-type:${orgSlug ? `${orgSlug}:` : ""}${teamSlug}:${meetingSlug}`);
}
@@ -1,10 +1,11 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import type { PageProps, SearchParams } from "app/_types";
import { generateMeetingMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
@@ -13,7 +14,20 @@ import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
import LegacyPage from "~/team/type-view";
import type { PageProps as LegacyPageProps } from "~/team/type-view";
import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData";
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
const featuresRepository = new FeaturesRepository();
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"team-booking-page-cache"
);
return isGloballyEnabled && searchParams.experimentalTeamBookingPageCache === "true";
}
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
if (await isCachedTeamBookingEnabled(await searchParams)) {
return await generateCachedMetadata({ params, searchParams });
}
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(legacyCtx);
const { booking, isSEOIndexable, eventData, isBrandingHidden } = props;
@@ -53,6 +67,10 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
if (await isCachedTeamBookingEnabled(await searchParams)) {
return await CachedTeamBooker({ params, searchParams });
}
const props = await getData(
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
);
@@ -70,4 +88,5 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
return <LegacyPage {...props} />;
};
export default ServerPage;
@@ -0,0 +1,218 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import type { PageProps, Params } from "app/_types";
import { generateMeetingMetadata } from "app/_utils";
import { headers, cookies } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookingForReschedule, type GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getOrgFullOrigin, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
import type { TeamData } from "@calcom/features/ee/teams/lib/getTeamData";
import { shouldHideBrandingForTeamEvent } from "@calcom/lib/hideBranding";
import { loadTranslations } from "@calcom/lib/server/i18n";
import slugify from "@calcom/lib/slugify";
import { BookingStatus, RedirectType } from "@calcom/prisma/enums";
import { buildLegacyCtx, buildLegacyRequest } from "@lib/buildLegacyCtx";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
import CachedClientView, { type TeamBookingPageProps } from "~/team/type-view-cached";
import { getCachedTeamData, getEnrichedEventType, getCRMData, shouldUseApiV2ForTeamSlots } from "./queries";
const paramsSchema = z.object({
slug: z.string().transform((s) => slugify(s)),
type: z.string().transform((s) => slugify(s)),
});
const _getTeamMetadataForBooking = (teamData: NonNullable<TeamData>, eventTypeId: number) => {
const organizationSettings = getOrganizationSEOSettings(teamData);
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
return {
orgBannerUrl: teamData.parent?.bannerUrl ?? "",
hideBranding: shouldHideBrandingForTeamEvent({
eventTypeId,
team: teamData,
}),
isSEOIndexable: allowSEOIndexing,
};
};
async function _getOrgContext(params: Params) {
const result = paramsSchema.safeParse({
slug: params?.slug,
type: params?.type,
});
if (!result.success) return notFound(); // should never happen
const { slug: teamSlug, type: meetingSlug } = result.data;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
buildLegacyRequest(await headers(), await cookies()),
params?.orgSlug ?? undefined
);
return {
currentOrgDomain,
isValidOrgDomain,
teamSlug,
meetingSlug,
};
}
const _getMultipleDurationValue = (
multipleDurationConfig: number[] | undefined,
queryDuration: string | string[] | null | undefined,
defaultValue: number
) => {
if (!multipleDurationConfig) return null;
if (multipleDurationConfig.includes(Number(queryDuration))) return Number(queryDuration);
return defaultValue;
};
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
const teamData = await getCachedTeamData(teamSlug, currentOrgDomain);
if (!teamData) return {}; // should never happen
const enrichedEventType = await getEnrichedEventType({
teamSlug,
meetingSlug,
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
fromRedirectOfNonOrgLink: (await searchParams).orgRedirection === "true",
});
if (!enrichedEventType) return {}; // should never happen
const title = enrichedEventType.title;
const profileName = enrichedEventType.profile.name ?? "";
const profileImage = enrichedEventType.profile.image;
const meeting = {
title,
profile: { name: profileName, image: profileImage },
users: [
...(enrichedEventType?.subsetOfUsers || []).map((user) => ({
name: `${user.name}`,
username: `${user.username}`,
})),
],
};
const { hideBranding, isSEOIndexable } = _getTeamMetadataForBooking(teamData, enrichedEventType.id);
const metadata = await generateMeetingMetadata(
meeting,
() => `${title} | ${profileName}`,
() => title,
hideBranding,
getOrgFullOrigin(enrichedEventType.entity.orgSlug ?? null),
`/team/${teamSlug}/${meetingSlug}`
);
return {
...metadata,
robots: {
follow: !(enrichedEventType.hidden || !isSEOIndexable),
index: !(enrichedEventType.hidden || !isSEOIndexable),
},
};
};
const CachedTeamBooker = async ({ params, searchParams }: PageProps) => {
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
// Handle org redirects for non-org contexts
if (!isOrgContext) {
const redirectResult = await getTemporaryOrgRedirect({
slugs: teamSlug,
redirectType: RedirectType.Team,
eventTypeSlug: meetingSlug,
currentQuery: legacyCtx.query,
});
if (redirectResult) return redirect(redirectResult.redirect.destination);
}
const teamData = await getCachedTeamData(teamSlug, currentOrgDomain);
if (!teamData) return notFound();
const enrichedEventType = await getEnrichedEventType({
teamSlug,
meetingSlug,
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
fromRedirectOfNonOrgLink: legacyCtx.query.orgRedirection === "true",
});
if (!enrichedEventType) return notFound();
// Handle rescheduling
const { rescheduleUid } = legacyCtx.query;
let bookingForReschedule: GetBookingType | null = null;
if (rescheduleUid) {
const session = await getServerSession({ req: legacyCtx.req });
bookingForReschedule = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
if (enrichedEventType.disableRescheduling) return redirect(`/booking/${rescheduleUid}`);
if (
bookingForReschedule?.status === BookingStatus.CANCELLED &&
legacyCtx.query.allowRescheduleForCancelledBooking !== "true" &&
!enrichedEventType.allowReschedulingCancelledBookings
) {
// redirecting to the same booking page without `rescheduleUid` search param
return redirect(`/team/${teamSlug}/${meetingSlug}`);
}
}
const [crmData, useApiV2] = await Promise.all([
getCRMData(legacyCtx.query, {
id: enrichedEventType.id,
isInstantEvent: enrichedEventType.isInstantEvent,
schedulingType: enrichedEventType.schedulingType,
metadata: enrichedEventType.metadata,
length: enrichedEventType.length,
}),
shouldUseApiV2ForTeamSlots(teamData.id),
]);
const props: TeamBookingPageProps = {
..._getTeamMetadataForBooking(teamData, enrichedEventType.id),
...crmData,
useApiV2,
isInstantMeeting: legacyCtx.query.isInstantMeeting === "true",
eventSlug: meetingSlug,
username: teamSlug,
eventData: {
...enrichedEventType,
},
entity: { ...enrichedEventType.entity },
bookingData: bookingForReschedule,
isTeamEvent: true,
durationConfig: enrichedEventType.metadata?.multipleDuration,
duration: _getMultipleDurationValue(
enrichedEventType.metadata?.multipleDuration,
legacyCtx.query.duration,
enrichedEventType.length
),
};
const Booker = <CachedClientView {...props} />;
const eventLocale = enrichedEventType.interfaceLanguage;
if (eventLocale) {
const ns = "common";
const translations = await loadTranslations(eventLocale, ns);
return (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
{Booker}
</CustomI18nProvider>
);
}
return Booker;
};
export default CachedTeamBooker;
@@ -0,0 +1,155 @@
import type { Prisma } from "@prisma/client";
import type { GetServerSidePropsContext } from "next";
import { unstable_cache } from "next/cache";
import { getTeamData } from "@calcom/features/ee/teams/lib/getTeamData";
import {
getEventTypeHosts,
getProfileFromEvent,
getUsersFromEvent,
processEventDataShared,
} from "@calcom/features/eventtypes/lib/getPublicEvent";
import { getTeamEventType } from "@calcom/features/eventtypes/lib/getTeamEventType";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import type { SchedulingType } from "@calcom/prisma/enums";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
export async function getCachedTeamData(teamSlug: string, orgSlug: string | null) {
return unstable_cache(async () => getTeamData(teamSlug, orgSlug), ["team-data", teamSlug, orgSlug ?? ""], {
revalidate: NEXTJS_CACHE_TTL,
tags: [`team:${orgSlug ? `${orgSlug}:` : ""}${teamSlug}`],
})();
}
export async function getCachedTeamEventType(teamSlug: string, meetingSlug: string, orgSlug: string | null) {
return unstable_cache(
async () => getTeamEventType(teamSlug, meetingSlug, orgSlug),
["team-event-type", teamSlug, meetingSlug, orgSlug ?? ""],
{
revalidate: NEXTJS_CACHE_TTL,
tags: [`event-type:${orgSlug ? `${orgSlug}:` : ""}${teamSlug}:${meetingSlug}`],
}
)();
}
export async function getEnrichedEventType({
teamSlug,
meetingSlug,
orgSlug,
fromRedirectOfNonOrgLink,
}: {
teamSlug: string;
meetingSlug: string;
orgSlug: string | null;
fromRedirectOfNonOrgLink: boolean;
}) {
const [teamData, eventType] = await Promise.all([
getCachedTeamData(teamSlug, orgSlug),
getCachedTeamEventType(teamSlug, meetingSlug, orgSlug),
]);
if (!teamData || !eventType) {
return null;
}
const { subsetOfHosts, hosts } = await getEventTypeHosts({
hosts: eventType.hosts,
prisma,
});
const enrichedOwner = eventType.owner
? await new UserRepository(prisma).enrichUserWithItsProfile({
user: eventType.owner,
})
: null;
const users =
(await getUsersFromEvent({ ...eventType, owner: enrichedOwner, subsetOfHosts, hosts }, prisma)) ?? [];
const name = teamData.parent?.name ?? teamData.name ?? null;
const isUnpublished = teamData.parent ? !teamData.parent.slug : !teamData.slug;
const eventMetaData = eventTypeMetaDataSchemaWithTypedApps.parse(eventType.metadata);
const eventDataShared = await processEventDataShared({
eventData: eventType,
metadata: eventMetaData,
prisma,
});
return {
...eventDataShared,
owner: enrichedOwner,
subsetOfHosts,
hosts,
profile: getProfileFromEvent({ ...eventType, owner: enrichedOwner, subsetOfHosts, hosts }),
subsetOfUsers: users,
users,
entity: {
fromRedirectOfNonOrgLink,
considerUnpublished: isUnpublished && !fromRedirectOfNonOrgLink,
orgSlug,
teamSlug: teamData.slug ?? null,
name,
hideProfileLink: false,
logoUrl: teamData.parent
? getPlaceholderAvatar(teamData.parent.logoUrl, teamData.parent.name)
: getPlaceholderAvatar(teamData.logoUrl, teamData.name),
},
};
}
export async function shouldUseApiV2ForTeamSlots(teamId: number): Promise<boolean> {
const featureRepo = new FeaturesRepository();
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(teamId, "use-api-v2-for-team-slots");
const useApiV2 = teamHasApiV2Route && Boolean(process.env.NEXT_PUBLIC_API_V2_URL);
return useApiV2;
}
export async function getCRMData(
query: GetServerSidePropsContext["query"],
eventData: {
id: number;
isInstantEvent: boolean;
schedulingType: SchedulingType | null;
metadata: Prisma.JsonValue | null;
length: number;
}
) {
const crmContactOwnerEmail = query["cal.crmContactOwnerEmail"];
const crmContactOwnerRecordType = query["cal.crmContactOwnerRecordType"];
const crmAppSlugParam = query["cal.crmAppSlug"];
let teamMemberEmail = Array.isArray(crmContactOwnerEmail) ? crmContactOwnerEmail[0] : crmContactOwnerEmail;
let crmOwnerRecordType = Array.isArray(crmContactOwnerRecordType)
? crmContactOwnerRecordType[0]
: crmContactOwnerRecordType;
let crmAppSlug = Array.isArray(crmAppSlugParam) ? crmAppSlugParam[0] : crmAppSlugParam;
if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) {
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
"@calcom/lib/server/getTeamMemberEmailFromCrm"
);
const {
email,
recordType,
crmAppSlug: crmAppSlugQuery,
} = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query,
eventData,
});
teamMemberEmail = email ?? undefined;
crmOwnerRecordType = recordType ?? undefined;
crmAppSlug = crmAppSlugQuery ?? undefined;
}
return {
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
};
}
@@ -0,0 +1,19 @@
"use client";
import { BookerWebWrapper, type BookerWebWrapperAtomProps } from "@calcom/atoms/booker";
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
import BookingPageErrorBoundary from "@components/error/BookingPageErrorBoundary";
export type TeamBookingPageProps = Omit<BookerWebWrapperAtomProps, "eventData"> &
Required<Pick<BookerWebWrapperAtomProps, "eventData">> & { isEmbed?: boolean };
export default function TeamBookingPage(props: TeamBookingPageProps) {
return (
<BookingPageErrorBoundary>
<main className={getBookerWrapperClasses({ isEmbed: !!props.isEmbed })}>
<BookerWebWrapper {...props} />
</main>
</BookingPageErrorBoundary>
);
}
@@ -31,6 +31,7 @@ import { TextField } from "@calcom/ui/components/form";
import { ImageUploader } from "@calcom/ui/components/image-uploader";
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTeamDataCache } from "@calcom/web/app/(booking-page-wrapper)/team/[slug]/[type]/actions";
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
import { subdomainSuffix } from "../../../organizations/lib/orgDomains";
@@ -66,6 +67,13 @@ const OtherTeamProfileView = () => {
},
async onSuccess() {
await utils.viewer.teams.get.invalidate();
if (team?.slug) {
// Org admins editing another team's profile should purge the cached team data
revalidateTeamDataCache({
teamSlug: team.slug,
orgSlug: team.parent?.slug ?? null,
});
}
showToast(t("your_team_updated_successfully"), "success");
},
});
@@ -11,6 +11,7 @@ import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Form } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTeamDataCache } from "@calcom/web/app/(booking-page-wrapper)/team/[slug]/[type]/actions";
import RoundRobinResetInterval from "./RoundRobinResetInterval";
import RoundRobinTimestampBasis from "./RoundRobinTimestampBasis";
@@ -38,6 +39,13 @@ const RoundRobinSettings = ({ team }: RoundRobinSettingsProps) => {
},
async onSuccess() {
await utils.viewer.teams.get.invalidate();
if (team?.slug) {
// Rounb robin reset interval / basis governs host selection logic on the booking page.
revalidateTeamDataCache({
teamSlug: team.slug,
orgSlug: team.parent?.slug ?? null,
});
}
showToast(t("round_robin_settings_updated_successfully"), "success");
},
});
@@ -0,0 +1,48 @@
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { prisma } from "@calcom/prisma";
export type TeamData = Awaited<ReturnType<typeof getTeamData>>;
export async function getTeamData(teamSlug: string, orgSlug: string | null) {
return await prisma.team.findFirst({
where: {
...getSlugOrRequestedSlug(teamSlug),
parent: orgSlug ? getSlugOrRequestedSlug(orgSlug) : null,
},
orderBy: {
slug: { sort: "asc", nulls: "last" },
},
select: {
id: true,
isPrivate: true,
hideBranding: true,
parent: {
select: {
id: true,
slug: true,
name: true,
bannerUrl: true,
logoUrl: true,
hideBranding: true,
organizationSettings: {
select: {
allowSEOIndexing: true,
},
},
},
},
logoUrl: true,
name: true,
slug: true,
brandColor: true,
darkBrandColor: true,
theme: true,
isOrganization: true,
organizationSettings: {
select: {
allowSEOIndexing: true,
},
},
},
});
}
@@ -18,6 +18,7 @@ import { Button } from "@calcom/ui/components/button";
import { Form } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTeamDataCache } from "@calcom/web/app/(booking-page-wrapper)/team/[slug]/[type]/actions";
import ThemeLabel from "../../../settings/ThemeLabel";
@@ -71,6 +72,14 @@ const ProfileView = ({ team }: ProfileViewProps) => {
}
showToast(t("your_team_updated_successfully"), "success");
if (res?.slug) {
// Appearance changes (theme, colours, branding toggles) are read on the team booking page through
// `getCachedTeamData` in `queries.ts`.
await revalidateTeamDataCache({
teamSlug: res?.slug,
orgSlug: team?.parent?.slug ?? null,
});
}
},
});
@@ -42,6 +42,7 @@ import {
} from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { revalidateTeamDataCache } from "@calcom/web/app/(booking-page-wrapper)/team/[slug]/[type]/actions";
import { revalidateEventTypesList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/event-types/actions";
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
@@ -283,6 +284,14 @@ const TeamProfileForm = ({ team, teamId }: TeamProfileFormProps) => {
// TODO: Not all changes require list invalidation
await utils.viewer.teams.list.invalidate();
revalidateTeamsList();
if (res?.slug) {
await revalidateTeamDataCache({
teamSlug: res.slug,
orgSlug: team?.parent?.slug ?? null,
});
}
showToast(t("your_team_updated_successfully"), "success");
},
});
@@ -21,6 +21,7 @@ import { Form } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import { CheckboxField } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTeamDataCache } from "@calcom/web/app/(booking-page-wrapper)/team/[slug]/[type]/actions";
import DisableTeamImpersonation from "../components/DisableTeamImpersonation";
import { default as InternalNotePresetsView } from "../components/InternalNotePresetsView";
@@ -57,6 +58,13 @@ const BookingLimitsView = ({ team }: ProfileViewProps) => {
includeManagedEventsInLimits: res.includeManagedEventsInLimits,
});
}
if (team?.slug) {
// Booking limits are enforced during slot generation, which relies on the same cached team data.
revalidateTeamDataCache({
teamSlug: team.slug,
orgSlug: team.parent?.slug ?? null,
});
}
showToast(t("booking_limits_updated_successfully"), "success");
},
});
@@ -50,7 +50,7 @@ const userSelect = {
defaultScheduleId: true,
} satisfies Prisma.UserSelect;
const getPublicEventSelect = (fetchAllUsers: boolean) => {
export const getPublicEventSelect = (fetchAllUsers: boolean) => {
return {
id: true,
title: true,
@@ -222,6 +222,33 @@ function isAvailableInTimeSlot(
}
export type PublicEventType = Awaited<ReturnType<typeof getPublicEvent>>;
export async function getEventTypeHosts({
hosts,
fetchAllUsers = false,
prisma,
}: {
hosts: Prisma.EventTypeGetPayload<{ select: ReturnType<typeof getPublicEventSelect> }>["hosts"];
fetchAllUsers?: boolean;
prisma: PrismaClient;
}) {
const usersAsHosts = hosts.map((host) => host.user);
// Enrich users in a single batch call
const enrichedUsers = await new UserRepository(prisma).enrichUsersWithTheirProfiles(usersAsHosts);
// Map enriched users back to the hosts
const enrichedHosts = hosts.map((host, index) => ({
...host,
user: enrichedUsers[index],
}));
return {
subsetOfHosts: enrichedHosts,
hosts: fetchAllUsers ? enrichedHosts : undefined,
};
}
// TODO: Convert it to accept a single parameter with structured data
export const getPublicEvent = async (
username: string,
@@ -535,6 +562,296 @@ export const getPublicEvent = async (
};
};
// A cleaner version of `getPublicEvent` that uses various helper functions like `processEventDataShared` and `getEventTypeHosts`
// There is still a lot of room for improvement, but it's a good starting point.
// For example, currently we have logic for all user / team / org / platform logic in this function.
// We should break this down into more dedicated functions.
const getPublicEventRefactored = async (
username: string,
eventSlug: string,
isTeamEvent: boolean | undefined,
org: string | null,
prisma: PrismaClient,
fromRedirectOfNonOrgLink: boolean,
currentUserId?: number,
fetchAllUsers = false
) => {
const usernameList = getUsernameList(username);
const orgQuery = org ? getSlugOrRequestedSlug(org) : null;
// In case of dynamic group event, we fetch user's data and use the default event.
if (usernameList.length > 1) {
const usersInOrgContext = await new UserRepository(prisma).findUsersByUsername({
usernameList,
orgSlug: org,
});
const users = usersInOrgContext;
const defaultEvent = getDefaultEvent(eventSlug);
let locations = defaultEvent.locations ? (defaultEvent.locations as LocationObject[]) : [];
// Get the preferred location type from the first user
const firstUsersMetadata = userMetadataSchema.parse(users[0].metadata || {});
const preferedLocationType = firstUsersMetadata?.defaultConferencingApp;
if (preferedLocationType?.appSlug) {
const foundApp = getAppFromSlug(preferedLocationType.appSlug);
const appType = foundApp?.appData?.location?.type;
if (appType) {
// Replace the location with the preferred location type
// This will still be default to daily if the app is not found
locations = [{ type: appType, link: preferedLocationType.appLink }] as LocationObject[];
}
}
const defaultEventBookerLayouts = {
enabledLayouts: [...bookerLayoutOptions],
defaultLayout: BookerLayouts.MONTH_VIEW,
} as BookerLayoutSettings;
const disableBookingTitle = !defaultEvent.isDynamic;
const unPublishedOrgUser = users.find((user) => user.profile?.organization?.slug === null);
let orgDetails: Pick<Team, "logoUrl" | "name"> | undefined;
if (org) {
orgDetails = await prisma.team.findFirstOrThrow({
where: {
slug: org,
},
select: {
logoUrl: true,
name: true,
},
});
}
return {
...defaultEvent,
bookingFields: getBookingFieldsWithSystemFields({ ...defaultEvent, disableBookingTitle }),
// Clears meta data since we don't want to send this in the public api.
subsetOfUsers: users.map((user) => ({
...user,
metadata: undefined,
bookerUrl: getBookerBaseUrlSync(user.profile?.organization?.slug ?? null),
})),
users: fetchAllUsers
? users.map((user) => ({
...user,
metadata: undefined,
bookerUrl: getBookerBaseUrlSync(user.profile?.organization?.slug ?? null),
}))
: undefined,
locations: privacyFilteredLocations(locations),
profile: {
weekStart: users[0].weekStart,
brandColor: users[0].brandColor,
darkBrandColor: users[0].darkBrandColor,
theme: null,
bookerLayouts: bookerLayoutsSchema.parse(
firstUsersMetadata?.defaultBookerLayouts || defaultEventBookerLayouts
),
...(orgDetails
? {
image: getPlaceholderAvatar(orgDetails?.logoUrl, orgDetails?.name),
name: orgDetails?.name,
username: org,
}
: {}),
},
entity: {
considerUnpublished: !fromRedirectOfNonOrgLink && unPublishedOrgUser !== undefined,
fromRedirectOfNonOrgLink,
orgSlug: org,
name: unPublishedOrgUser?.profile?.organization?.name ?? null,
teamSlug: null,
logoUrl: null,
hideProfileLink: false,
},
isInstantEvent: false,
instantMeetingParameters: [],
showInstantEventConnectNowModal: false,
autoTranslateDescriptionEnabled: false,
fieldTranslations: [],
};
}
const usersOrTeamQuery = isTeamEvent
? {
team: {
...getSlugOrRequestedSlug(username),
parent: orgQuery,
},
}
: {
users: {
some: {
...(orgQuery
? {
profiles: {
some: {
organization: orgQuery,
username: username,
},
},
}
: {
username,
profiles: { none: {} },
}),
},
},
team: null,
};
// In case it's not a group event, it's either a single user or a team, and we query that data.
let event = await prisma.eventType.findFirst({
where: {
slug: eventSlug,
...usersOrTeamQuery,
},
select: getPublicEventSelect(fetchAllUsers),
});
// If no event was found, check for platform org user event
if (!event && !orgQuery) {
event = await prisma.eventType.findFirst({
where: {
slug: eventSlug,
users: {
some: {
username,
isPlatformManaged: false,
profiles: {
some: {
organization: {
isPlatform: true,
},
},
},
},
},
},
select: getPublicEventSelect(fetchAllUsers),
});
}
if (!event) return null;
const eventMetaData = eventTypeMetaDataSchemaWithTypedApps.parse(event.metadata || {});
const teamMetadata = teamMetadataSchema.parse(event.team?.metadata || {});
const { subsetOfHosts, hosts } = await getEventTypeHosts({
hosts: event.hosts,
fetchAllUsers,
prisma,
});
const eventWithUserProfiles = {
...event,
owner: event.owner
? await new UserRepository(prisma).enrichUserWithItsProfile({
user: event.owner,
})
: null,
subsetOfHosts,
hosts,
};
let users =
(await getUsersFromEvent(eventWithUserProfiles, prisma)) ||
(await getOwnerFromUsersArray(prisma, event.id));
if (users === null) {
throw new Error(`EventType ${event.id} has no owner or users.`);
}
//In case the event schedule is not defined ,use the event owner's default schedule
if (!eventWithUserProfiles.schedule && eventWithUserProfiles.owner?.defaultScheduleId) {
const eventOwnerDefaultSchedule = await prisma.schedule.findUnique({
where: {
id: eventWithUserProfiles.owner?.defaultScheduleId,
},
select: {
id: true,
timeZone: true,
},
});
eventWithUserProfiles.schedule = eventOwnerDefaultSchedule;
}
let orgDetails: Pick<Team, "logoUrl" | "name"> | undefined | null;
if (org) {
orgDetails = await prisma.team.findFirst({
where: {
slug: org,
parentId: null,
},
select: {
logoUrl: true,
name: true,
},
});
}
const isTeamAdminOrOwner = await prisma.membership.findFirst({
where: {
userId: currentUserId ?? -1,
teamId: event.teamId ?? -1,
accepted: true,
role: { in: ["ADMIN", "OWNER"] },
},
});
const isOrgAdminOrOwner = await prisma.membership.findFirst({
where: {
userId: currentUserId ?? -1,
teamId: event.team?.parentId ?? -1,
accepted: true,
role: { in: ["ADMIN", "OWNER"] },
},
});
if (event.team?.isPrivate && !isTeamAdminOrOwner && !isOrgAdminOrOwner) {
users = [];
}
const eventDataShared = await processEventDataShared({
eventData: event,
metadata: eventMetaData,
prisma,
});
return {
...eventDataShared,
// getPublicEvent-specific overrides
owner: eventWithUserProfiles.owner,
subsetOfHosts,
hosts,
// Sets user data on profile object for easier access
profile: getProfileFromEvent(eventWithUserProfiles),
subsetOfUsers: users,
users: fetchAllUsers ? users : undefined,
entity: {
fromRedirectOfNonOrgLink,
considerUnpublished:
!fromRedirectOfNonOrgLink &&
(eventWithUserProfiles.team?.slug === null ||
eventWithUserProfiles.owner?.profile?.organization?.slug === null ||
eventWithUserProfiles.team?.parent?.slug === null),
orgSlug: org,
teamSlug: (eventWithUserProfiles.team?.slug || teamMetadata?.requestedSlug) ?? null,
name:
(eventWithUserProfiles.owner?.profile?.organization?.name ||
eventWithUserProfiles.team?.parent?.name ||
eventWithUserProfiles.team?.name) ??
null,
hideProfileLink: eventWithUserProfiles.team?.hideTeamProfileLink ?? false,
...(orgDetails
? {
logoUrl: getPlaceholderAvatar(orgDetails?.logoUrl, orgDetails?.name),
name: orgDetails?.name,
}
: {}),
},
};
};
const eventData = {
select: getPublicEventSelect(true),
} satisfies Prisma.EventTypeArgs;
@@ -546,7 +863,7 @@ type GetProfileFromEventInput = Omit<Event, "hosts"> & {
subsetOfHosts: Event["hosts"];
};
function getProfileFromEvent(event: GetProfileFromEventInput) {
export function getProfileFromEvent(event: GetProfileFromEventInput) {
const { team, subsetOfHosts: hosts, owner } = event;
const nonTeamprofile = hosts?.[0]?.user || owner;
const profile = team || nonTeamprofile;
@@ -576,7 +893,7 @@ function getProfileFromEvent(event: GetProfileFromEventInput) {
};
}
async function getUsersFromEvent(
export async function getUsersFromEvent(
event: Omit<Event, "owner" | "hosts"> & {
owner:
| (Event["owner"] & {
@@ -673,3 +990,39 @@ function mapHostsToUsers(host: {
profile: host.user.profile,
};
}
export const processEventDataShared = async ({
eventData,
metadata,
prisma,
}: {
eventData: Prisma.EventTypeGetPayload<{ select: ReturnType<typeof getPublicEventSelect> }>;
metadata: ReturnType<typeof eventTypeMetaDataSchemaWithTypedApps.parse>;
prisma: PrismaClient;
}) => {
let showInstantEventConnectNowModal = eventData.isInstantEvent ?? false;
if (eventData.isInstantEvent && eventData.instantMeetingSchedule?.id) {
const { id, timeZone } = eventData.instantMeetingSchedule;
showInstantEventConnectNowModal = await isCurrentlyAvailable({
prisma,
instantMeetingScheduleId: id,
availabilityTimezone: timeZone ?? "Europe/London",
length: eventData.length,
});
}
return {
...eventData,
bookerLayouts: bookerLayoutsSchema.parse(metadata?.bookerLayouts || null),
description: markdownToSafeHTML(eventData.description),
metadata,
customInputs: customInputSchema.array().parse(eventData.customInputs || []),
locations: privacyFilteredLocations((eventData.locations || []) as LocationObject[]),
bookingFields: getBookingFieldsWithSystemFields(eventData),
recurringEvent: isRecurringEvent(eventData.recurringEvent)
? parseRecurringEvent(eventData.recurringEvent)
: null,
isDynamic: false,
showInstantEventConnectNowModal,
};
};
@@ -0,0 +1,22 @@
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { prisma } from "@calcom/prisma";
import { getPublicEventSelect } from "./getPublicEvent";
export async function getTeamEventType(teamSlug: string, meetingSlug: string, orgSlug: string | null) {
return await prisma.eventType.findFirst({
where: {
team: {
...getSlugOrRequestedSlug(teamSlug),
parent: orgSlug ? getSlugOrRequestedSlug(orgSlug) : null,
},
OR: [{ slug: meetingSlug }, { slug: { startsWith: `${meetingSlug}-team-id-` } }],
},
// IMPORTANT:
// This ensures that the queried event type has everything expected in Booker
select: getPublicEventSelect(false),
orderBy: {
slug: "asc",
},
});
}
+1
View File
@@ -23,6 +23,7 @@ export type AppFlags = {
"use-api-v2-for-team-slots": boolean;
pbac: boolean;
"restriction-schedule": boolean;
"team-booking-page-cache": boolean;
};
export type TeamFeatures = Record<keyof AppFlags, boolean>;
+1
View File
@@ -22,6 +22,7 @@ const initialData: AppFlags = {
"use-api-v2-for-team-slots": false,
pbac: false,
"restriction-schedule": false,
"team-booking-page-cache": false,
};
if (process.env.NEXT_PUBLIC_IS_E2E) {
+2
View File
@@ -227,3 +227,5 @@ export const CAL_VIDEO_MEETING_LINK_FOR_TESTING = process.env.CAL_VIDEO_MEETING_
export const IS_SMS_CREDITS_ENABLED = !!process.env.NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID;
export const DATABASE_CHUNK_SIZE = parseInt(process.env.DATABASE_CHUNK_SIZE || "25", 10);
export const NEXTJS_CACHE_TTL = 3600; // 1 hour
@@ -26,7 +26,7 @@ import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR, WEBAPP_URL } from
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
type BookerWebWrapperAtomProps = BookerProps & {
export type BookerWebWrapperAtomProps = BookerProps & {
eventData?: NonNullable<Awaited<ReturnType<typeof getPublicEvent>>>;
};
+1
View File
@@ -1 +1,2 @@
export { BookerWebWrapper } from "./BookerWebWrapper";
export type { BookerWebWrapperAtomProps } from "./BookerWebWrapper";
@@ -20,6 +20,7 @@ import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTeamEventTypeCache } from "@calcom/web/app/(booking-page-wrapper)/team/[slug]/[type]/actions";
import { revalidateEventTypeEditPage } from "@calcom/web/app/(use-page-wrapper)/event-types/[type]/actions";
import { TRPCClientError } from "@trpc/react-query";
@@ -138,6 +139,15 @@ const EventTypeWeb = ({ id, ...rest }: EventTypeSetupProps & { id: number }) =>
// Reset the form with these values as new default values to ensure the correct comparison for dirtyFields eval
form.reset(currentValues);
revalidateEventTypeEditPage(eventType.id);
if (eventType.team?.slug) {
// When an event-type is updated,
// guests could still hit a stale cache and see the old page.
revalidateTeamEventTypeCache({
teamSlug: eventType.team.slug,
meetingSlug: eventType.slug,
orgSlug: eventType.team.parent?.slug ?? null,
});
}
showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success");
},
async onSettled() {
@@ -311,6 +321,15 @@ const EventTypeWeb = ({ id, ...rest }: EventTypeSetupProps & { id: number }) =>
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
onSuccess: async () => {
await utils.viewer.eventTypes.invalidate();
if (team?.slug) {
// When a team event-type is deleted,
// guests could still hit a stale cache and see the old page.
revalidateTeamEventTypeCache({
teamSlug: team.slug,
meetingSlug: eventType.slug,
orgSlug: team.parent?.slug ?? null,
});
}
showToast(t("event_type_deleted_successfully"), "success");
isTeamEventTypeDeleted.current = true;
appRouter.push("/event-types");