Files
calendar/apps/web/lib/team/[slug]/getServerSideProps.tsx
T
Keith WilliamsGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
b0835e8ae3 perf: reduce team page client payload with minimal data serialization (#27656)
* 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>
2026-02-05 12:03:37 -03:00

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 }
: {}),
};
}