diff --git a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/actions.ts b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/actions.ts new file mode 100644 index 0000000000..c3b7921bef --- /dev/null +++ b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/actions.ts @@ -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}`); +} diff --git a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/page.tsx b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/page.tsx index abd581b2da..77b685ffb3 100644 --- a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/page.tsx +++ b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/page.tsx @@ -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 { + 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(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 ; }; + export default ServerPage; diff --git a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/pageWithCachedData.tsx b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/pageWithCachedData.tsx new file mode 100644 index 0000000000..56f7fc095f --- /dev/null +++ b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/pageWithCachedData.tsx @@ -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, 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 = ; + + const eventLocale = enrichedEventType.interfaceLanguage; + if (eventLocale) { + const ns = "common"; + const translations = await loadTranslations(eventLocale, ns); + return ( + + {Booker} + + ); + } + + return Booker; +}; + +export default CachedTeamBooker; diff --git a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/queries.ts b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/queries.ts new file mode 100644 index 0000000000..8eb16223e6 --- /dev/null +++ b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/queries.ts @@ -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 { + 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, + }; +} diff --git a/apps/web/modules/team/type-view-cached.tsx b/apps/web/modules/team/type-view-cached.tsx new file mode 100644 index 0000000000..b679bd88ed --- /dev/null +++ b/apps/web/modules/team/type-view-cached.tsx @@ -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 & + Required> & { isEmbed?: boolean }; + +export default function TeamBookingPage(props: TeamBookingPageProps) { + return ( + +
+ +
+
+ ); +} diff --git a/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx b/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx index 7942dd6f53..09bf717dbe 100644 --- a/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx +++ b/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx @@ -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"); }, }); diff --git a/packages/features/ee/teams/components/RoundRobinSettings.tsx b/packages/features/ee/teams/components/RoundRobinSettings.tsx index d5419ccf9e..702ca2afaf 100644 --- a/packages/features/ee/teams/components/RoundRobinSettings.tsx +++ b/packages/features/ee/teams/components/RoundRobinSettings.tsx @@ -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"); }, }); diff --git a/packages/features/ee/teams/lib/getTeamData.ts b/packages/features/ee/teams/lib/getTeamData.ts new file mode 100644 index 0000000000..4e7de4f010 --- /dev/null +++ b/packages/features/ee/teams/lib/getTeamData.ts @@ -0,0 +1,48 @@ +import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { prisma } from "@calcom/prisma"; + +export type TeamData = Awaited>; + +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, + }, + }, + }, + }); +} diff --git a/packages/features/ee/teams/pages/team-appearance-view.tsx b/packages/features/ee/teams/pages/team-appearance-view.tsx index 90a23a9dea..3881697130 100644 --- a/packages/features/ee/teams/pages/team-appearance-view.tsx +++ b/packages/features/ee/teams/pages/team-appearance-view.tsx @@ -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, + }); + } }, }); diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx index 8d36de19d4..471d202849 100644 --- a/packages/features/ee/teams/pages/team-profile-view.tsx +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -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"); }, }); diff --git a/packages/features/ee/teams/pages/team-settings-view.tsx b/packages/features/ee/teams/pages/team-settings-view.tsx index dd9cf976fe..1f11df6a5d 100644 --- a/packages/features/ee/teams/pages/team-settings-view.tsx +++ b/packages/features/ee/teams/pages/team-settings-view.tsx @@ -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"); }, }); diff --git a/packages/features/eventtypes/lib/getPublicEvent.ts b/packages/features/eventtypes/lib/getPublicEvent.ts index abf5879ae2..c1ca3d5b27 100644 --- a/packages/features/eventtypes/lib/getPublicEvent.ts +++ b/packages/features/eventtypes/lib/getPublicEvent.ts @@ -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>; + +export async function getEventTypeHosts({ + hosts, + fetchAllUsers = false, + prisma, +}: { + hosts: Prisma.EventTypeGetPayload<{ select: ReturnType }>["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 | 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 | 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 & { 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 & { 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 }>; + metadata: ReturnType; + 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, + }; +}; diff --git a/packages/features/eventtypes/lib/getTeamEventType.ts b/packages/features/eventtypes/lib/getTeamEventType.ts new file mode 100644 index 0000000000..9d73818a85 --- /dev/null +++ b/packages/features/eventtypes/lib/getTeamEventType.ts @@ -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", + }, + }); +} diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 7d868b2ed3..d6501dd9a0 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -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; diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts index 7c6ce98452..80942c3ac2 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -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) { diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 1089e628ed..2f31b068c7 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -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 diff --git a/packages/platform/atoms/booker/BookerWebWrapper.tsx b/packages/platform/atoms/booker/BookerWebWrapper.tsx index 7b81d5516d..4f7f3fd4b5 100644 --- a/packages/platform/atoms/booker/BookerWebWrapper.tsx +++ b/packages/platform/atoms/booker/BookerWebWrapper.tsx @@ -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>>; }; diff --git a/packages/platform/atoms/booker/index.ts b/packages/platform/atoms/booker/index.ts index 91e5954299..9fbfb6a67f 100644 --- a/packages/platform/atoms/booker/index.ts +++ b/packages/platform/atoms/booker/index.ts @@ -1 +1,2 @@ export { BookerWebWrapper } from "./BookerWebWrapper"; +export type { BookerWebWrapperAtomProps } from "./BookerWebWrapper"; diff --git a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx index 5834db70cb..b59d876e51 100644 --- a/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx +++ b/packages/platform/atoms/event-types/wrappers/EventTypeWebWrapper.tsx @@ -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");