* perf: optimize team page data fetching by reducing host user data For team view pages (/team/[slug]), event type hosts only need minimal user data for avatar display (id, name, username, avatarUrl). Previously, the full userSelect was used which included: - teams (with nested team data) - credentials (with app and destinationCalendars) - email, bio This optimization reduces data transfer significantly for teams with many event types and hosts. With 441K requests and 54GB outgoing data (~128KB per request), even small reductions per request add up. The full userSelect is still used when !isTeamView to support the connectedApps feature. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * perf: optimize team page serialization to reduce data transfer - Replace spread operators with explicit field selection for eventTypes - Only send minimal user data needed for UserAvatarGroup: name, username, avatarUrl, profile - Override eventTypes in return props with minimalEventTypes - Reduces per-request data transfer by excluding unnecessary fields like email, bio, teams, credentials Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: add missing fields needed by EventTypeDescription component Add metadata, seatsPerTimeSlot, requiresConfirmation to minimalEventTypes Add id to user object for proper key handling Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * revert: remove serialization changes that broke TypeScript types Keep only the query-level optimization in queries.ts which reduces database load by fetching minimal user data for event type hosts when isTeamView=true. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * perf: reduce team page client payload with explicit DTO types - Create TeamPage.types.ts with explicit DTO types for minimal data - Update getServerSideProps.tsx to return only fields needed by the UI - Update team-view.tsx to use explicit types instead of inferSSRProps This reduces the data sent to the client by: - Event types: only id, title, slug, description, length, schedulingType, recurringEvent, metadata, requiresConfirmation, seatsPerTimeSlot - Event type users: only id, name, username, avatarUrl, avatar, profile - Members: only fields needed by Team component - Children: only slug and name - Parent: only id, slug, name, isOrganization, isPrivate, logoUrl Combined with the query-level optimization, this should significantly reduce the ~128KB average payload per request. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Revert: explicit DTO approach due to type compatibility issues The explicit DTO types broke compatibility with existing components (EventTypeDescription, UserAvatarGroup) which expect specific type structures. Keeping only the query-level optimization. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * perf: reduce team page client payload with minimal data serialization - Update EventTypeDescription to accept minimal type (only fields actually used) - Update UserAvatarGroup to accept minimal user type - Update UserAvatar to accept minimal profile type - Update Team component to accept minimal member type - Update getServerSideProps to explicitly select only needed fields This reduces the ~128KB client payload by removing unused fields from: - Event types: removed hidden, price, currency, lockTimeZoneToggleOnBookingPage, etc. - Event type users: only send name, username, avatarUrl, avatar, profile.username, profile.organization.slug - Team members: only send fields needed by Team component - Team parent/children: only send fields needed for display Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: revert component type changes, keep serialization optimization - Revert EventTypeDescription, UserAvatarGroup, UserAvatar, Team component types to original - Keep serialization optimization in getServerSideProps.tsx - Add missing fields (price, currency, hidden, etc.) for EventTypeDescription compatibility - Add full profile structure for UserAvatarGroup compatibility This reduces client payload by explicitly selecting only needed fields while maintaining type compatibility with existing component usages. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: add missing fields for component type compatibility - Add lockedTimeZone and canSendCalVideoTranscriptionEmails for EventTypeDescription - Add organizationId to members for Team component MemberType - Add name, calVideoLogo, bannerUrl to organization for UserProfile type - Reorder fields to match baseEventTypeSelect structure Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
import type { GetServerSidePropsContext } from "next";
|
|
|
|
import { getBookerBaseUrlSync } from "@calcom/features/ee/organizations/lib/getBookerBaseUrlSync";
|
|
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
|
import {
|
|
getOrganizationSettings,
|
|
getVerifiedDomain,
|
|
} from "@calcom/features/ee/organizations/lib/orgSettings";
|
|
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
|
|
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
|
import { IS_CALCOM } from "@calcom/lib/constants";
|
|
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
|
import logger from "@calcom/lib/logger";
|
|
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
|
import slugify from "@calcom/lib/slugify";
|
|
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
|
import prisma from "@calcom/prisma";
|
|
import type { Team, OrganizationSettings } from "@calcom/prisma/client";
|
|
import { RedirectType } from "@calcom/prisma/enums";
|
|
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
|
|
|
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
|
|
|
|
const log = logger.getSubLogger({ prefix: ["team/[slug]"] });
|
|
|
|
function getOrgProfileRedirectToVerifiedDomain(
|
|
team: {
|
|
isOrganization: boolean;
|
|
},
|
|
settings: Pick<OrganizationSettings, "orgAutoAcceptEmail" | "orgProfileRedirectsToVerifiedDomain">
|
|
) {
|
|
if (!team.isOrganization) {
|
|
return null;
|
|
}
|
|
// when this is not on a Cal.com page we don't auto redirect -
|
|
// good for diagnosis purposes.
|
|
if (!IS_CALCOM) {
|
|
return null;
|
|
}
|
|
|
|
const verifiedDomain = getVerifiedDomain(settings);
|
|
|
|
if (!settings.orgProfileRedirectsToVerifiedDomain || !verifiedDomain) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
redirect: {
|
|
permanent: false,
|
|
destination: `https://${verifiedDomain}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
const getTheLastArrayElement = (value: ReadonlyArray<string> | string | undefined): string | undefined => {
|
|
if (value === undefined || typeof value === "string") {
|
|
return value;
|
|
}
|
|
|
|
return value.at(-1);
|
|
};
|
|
|
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
|
const slug = getTheLastArrayElement(context.query.slug) ?? getTheLastArrayElement(context.query.orgSlug);
|
|
|
|
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(
|
|
context.req,
|
|
context.params?.orgSlug ?? context.query?.orgSlug
|
|
);
|
|
|
|
// Provided by Rewrite from next.config.js
|
|
const isOrgProfile = context.query?.isOrgProfile === "1";
|
|
const featuresRepository = new FeaturesRepository(prisma);
|
|
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
|
|
|
log.debug("getServerSideProps", {
|
|
isOrgProfile,
|
|
isOrganizationFeatureEnabled: organizationsEnabled,
|
|
isValidOrgDomain,
|
|
currentOrgDomain,
|
|
});
|
|
|
|
const team = await getTeamWithMembers({
|
|
// It only finds those teams that have slug set. So, if only requestedSlug is set, it won't get that team
|
|
slug: slugify(slug ?? ""),
|
|
orgSlug: currentOrgDomain,
|
|
isTeamView: true,
|
|
isOrgView: isValidOrgDomain && isOrgProfile,
|
|
});
|
|
|
|
if (slug) {
|
|
const redirect = await handleOrgRedirect({
|
|
slugs: [slug],
|
|
redirectType: RedirectType.Team,
|
|
eventTypeSlug: null,
|
|
context,
|
|
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
|
|
});
|
|
|
|
if (redirect) {
|
|
return redirect;
|
|
}
|
|
}
|
|
|
|
const metadata = teamMetadataSchema.parse(team?.metadata ?? {});
|
|
|
|
// Taking care of sub-teams and orgs
|
|
if (
|
|
(!isValidOrgDomain && team?.parent) ||
|
|
(!isValidOrgDomain && !!team?.isOrganization) ||
|
|
!organizationsEnabled
|
|
) {
|
|
return { notFound: true } as const;
|
|
}
|
|
|
|
if (!team) {
|
|
// Because we are fetching by requestedSlug being set, it can either be an organization or a regular team. But it can't be a sub-team i.e.
|
|
const unpublishedTeam = await prisma.team.findFirst({
|
|
where: {
|
|
metadata: {
|
|
path: ["requestedSlug"],
|
|
equals: slug,
|
|
},
|
|
},
|
|
include: {
|
|
parent: {
|
|
select: {
|
|
id: true,
|
|
slug: true,
|
|
name: true,
|
|
isPrivate: true,
|
|
isOrganization: true,
|
|
metadata: true,
|
|
logoUrl: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!unpublishedTeam) return { notFound: true } as const;
|
|
const teamParent = unpublishedTeam.parent ? getTeamWithoutMetadata(unpublishedTeam.parent) : null;
|
|
return {
|
|
props: {
|
|
considerUnpublished: true,
|
|
team: {
|
|
...unpublishedTeam,
|
|
parent: teamParent,
|
|
createdAt: null,
|
|
},
|
|
},
|
|
} as const;
|
|
}
|
|
|
|
const organizationSettings = getOrganizationSettings(team);
|
|
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
|
|
|
|
const redirectToVerifiedDomain = organizationSettings
|
|
? getOrgProfileRedirectToVerifiedDomain(team, organizationSettings)
|
|
: null;
|
|
|
|
if (redirectToVerifiedDomain) {
|
|
return redirectToVerifiedDomain;
|
|
}
|
|
|
|
const isTeamOrParentOrgPrivate = team.isPrivate || (team.parent?.isOrganization && team.parent?.isPrivate);
|
|
|
|
const minimalEventTypes =
|
|
team.eventTypes?.map((type) => ({
|
|
// Fields from baseEventTypeSelect (except description which becomes descriptionAsSafeHTML)
|
|
id: type.id,
|
|
title: type.title,
|
|
slug: type.slug,
|
|
length: type.length,
|
|
schedulingType: type.schedulingType,
|
|
recurringEvent: type.recurringEvent,
|
|
hidden: type.hidden,
|
|
price: type.price,
|
|
currency: type.currency,
|
|
lockTimeZoneToggleOnBookingPage: type.lockTimeZoneToggleOnBookingPage,
|
|
lockedTimeZone: type.lockedTimeZone,
|
|
requiresConfirmation: type.requiresConfirmation,
|
|
requiresBookerEmailVerification: type.requiresBookerEmailVerification,
|
|
canSendCalVideoTranscriptionEmails: type.canSendCalVideoTranscriptionEmails,
|
|
seatsPerTimeSlot: type.seatsPerTimeSlot,
|
|
// Additional fields needed by EventTypeDescription
|
|
metadata: type.metadata,
|
|
descriptionAsSafeHTML: markdownToSafeHTML(type.description),
|
|
users: !isTeamOrParentOrgPrivate
|
|
? type.users.map((user) => ({
|
|
name: user.name,
|
|
username: user.username,
|
|
avatarUrl: user.avatarUrl,
|
|
avatar: getUserAvatarUrl(user),
|
|
profile: {
|
|
id: user.profile.id,
|
|
upId: user.profile.upId,
|
|
username: user.profile.username,
|
|
organizationId: user.profile.organizationId,
|
|
organization: user.profile.organization
|
|
? {
|
|
id: user.profile.organization.id,
|
|
slug: user.profile.organization.slug,
|
|
name: user.profile.organization.name,
|
|
requestedSlug: user.profile.organization.requestedSlug,
|
|
calVideoLogo: user.profile.organization.calVideoLogo,
|
|
bannerUrl: user.profile.organization.bannerUrl,
|
|
}
|
|
: null,
|
|
},
|
|
}))
|
|
: [],
|
|
})) ?? null;
|
|
|
|
const safeBio = markdownToSafeHTML(team.bio) || "";
|
|
|
|
const minimalMembers = !isTeamOrParentOrgPrivate
|
|
? team.members.map((member) => ({
|
|
id: member.id,
|
|
name: member.name,
|
|
username: member.username,
|
|
avatarUrl: member.avatarUrl,
|
|
bio: member.bio,
|
|
organizationId: member.organizationId,
|
|
subteams: member.subteams,
|
|
accepted: member.accepted,
|
|
profile: {
|
|
id: member.profile.id,
|
|
username: member.profile.username,
|
|
organizationId: member.profile.organizationId,
|
|
organization: member.profile.organization
|
|
? {
|
|
id: member.profile.organization.id,
|
|
slug: member.profile.organization.slug,
|
|
name: member.profile.organization.name,
|
|
requestedSlug: member.profile.organization.requestedSlug,
|
|
calVideoLogo: member.profile.organization.calVideoLogo,
|
|
bannerUrl: member.profile.organization.bannerUrl,
|
|
}
|
|
: null,
|
|
},
|
|
safeBio: markdownToSafeHTML(member.bio || ""),
|
|
bookerUrl: getBookerBaseUrlSync(member.organization?.slug || ""),
|
|
}))
|
|
: [];
|
|
|
|
const markdownStrippedBio = stripMarkdown(team?.bio || "");
|
|
|
|
const minimalParent = team.parent
|
|
? {
|
|
id: team.parent.id,
|
|
slug: team.parent.slug,
|
|
name: team.parent.name,
|
|
isOrganization: team.parent.isOrganization,
|
|
isPrivate: team.parent.isPrivate,
|
|
logoUrl: team.parent.logoUrl,
|
|
requestedSlug: teamMetadataSchema.parse(team.parent.metadata)?.requestedSlug ?? null,
|
|
}
|
|
: null;
|
|
|
|
const minimalChildren = isTeamOrParentOrgPrivate
|
|
? []
|
|
: team.children.map((child) => ({
|
|
slug: child.slug,
|
|
name: child.name,
|
|
}));
|
|
|
|
// For a team or Organization we check if it's unpublished
|
|
// For a subteam, we check if the parent org is unpublished. A subteam can't be unpublished in itself
|
|
const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
|
|
const isARedirectFromNonOrgLink = context.query.orgRedirection === "true";
|
|
|
|
const considerUnpublished = isUnpublished && !isARedirectFromNonOrgLink;
|
|
|
|
if (considerUnpublished) {
|
|
return {
|
|
props: {
|
|
considerUnpublished: true,
|
|
team: {
|
|
id: team.id,
|
|
slug: team.slug,
|
|
name: team.name,
|
|
isOrganization: team.isOrganization,
|
|
logoUrl: team.logoUrl,
|
|
metadata,
|
|
parent: minimalParent,
|
|
createdAt: null,
|
|
},
|
|
},
|
|
} as const;
|
|
}
|
|
|
|
return {
|
|
props: {
|
|
team: {
|
|
id: team.id,
|
|
slug: team.slug,
|
|
name: team.name,
|
|
bio: team.bio,
|
|
safeBio,
|
|
theme: team.theme,
|
|
isPrivate: team.isPrivate,
|
|
isOrganization: team.isOrganization,
|
|
hideBookATeamMember: team.hideBookATeamMember,
|
|
logoUrl: team.logoUrl,
|
|
brandColor: team.brandColor,
|
|
darkBrandColor: team.darkBrandColor,
|
|
metadata,
|
|
parent: minimalParent,
|
|
eventTypes: minimalEventTypes,
|
|
members: minimalMembers,
|
|
children: minimalChildren,
|
|
},
|
|
themeBasis: team.slug,
|
|
markdownStrippedBio,
|
|
isValidOrgDomain,
|
|
currentOrgDomain,
|
|
isSEOIndexable: allowSEOIndexing,
|
|
},
|
|
} as const;
|
|
};
|
|
|
|
/**
|
|
* Removes metadata from team and just adds requestedSlug
|
|
*/
|
|
function getTeamWithoutMetadata<T extends Pick<Team, "metadata">>(team: T) {
|
|
const { metadata, ...rest } = team;
|
|
const teamMetadata = teamMetadataSchema.parse(metadata);
|
|
return {
|
|
...rest,
|
|
...(typeof teamMetadata?.requestedSlug !== "undefined"
|
|
? { requestedSlug: teamMetadata?.requestedSlug }
|
|
: {}),
|
|
};
|
|
}
|