fix: Apply organization brand colors and theme to member personal events (#24456)

* fix: Apply organization brand colors and theme to member personal events

- Fetch organization brand colors and theme in getEventTypesFromDB
- Override user brand colors/theme with org values for personal events
- Apply to booking confirmation pages, user booking pages, and routing forms
- Follow same pattern as hideBranding for consistency
- Fixes issue where org brand colors only applied to team events

Co-Authored-By: [email protected] <[email protected]>

* fix: Resolve type errors for organization brand color and theme overrides

- Added brandColor, darkBrandColor, theme to ProfileRepository findById organization select
- Updated test mock to include new organization fields
- Ensures consistent organization branding types across all queries
- All type errors related to brand color changes now resolved

Co-Authored-By: [email protected] <[email protected]>

* fix: Remove redundant profile assignment causing type errors

The spread operator already includes profile when it exists.
Explicit assignment causes type conflicts with union types where
profile is not guaranteed to exist in all branches.

Resolves TypeScript error at bookings-single-view.getServerSideProps.tsx:134

Co-Authored-By: [email protected] <[email protected]>

* fix: Use eventTypeRaw.profile for organization brand color access

Changed eventType.profile to eventTypeRaw.profile in profile object construction
to fix TypeScript errors where profile property doesn't exist on transformed
eventType object. The eventTypeRaw object maintains the original profile field
from the database query.

Resolves TypeScript errors at lines 155, 158, 161 in
bookings-single-view.getServerSideProps.tsx

Co-Authored-By: [email protected] <[email protected]>

* fix type error

* refactor: Extract branding logic into getBrandingForEventType utility

- Create getBrandingForEventType utility function that handles organization
  brand color overrides for both team and personal events
- For team events: org branding → team branding → null
- For personal events: org branding → user branding → null
- Refactor bookings-single-view to use utility function
- Update team booking page to apply organization brand overrides
- Add organization branding fields to ProfileRepository and team queries

Addresses review feedback from @hariombalhara on PR #24456

Co-Authored-By: [email protected] <[email protected]>

* Update getBranding.ts

* Change

Set themeBasis to null instead of branding.theme.

* refactor: Simplify branding fallback logic to prevent mixing org/team settings

- Changed getBrandingForEventType to use either parent or team branding entirely
- Simplified team parent branding structure in getServerSideProps
- Prevents inconsistent branding when some properties exist in parent but not others
- Addresses PR review feedback from hariombalhara

Co-Authored-By: [email protected] <[email protected]>

* addressed review

* test: Add comprehensive unit tests for branding utility functions

- Add 20 unit tests covering getBrandingForEventType, getBrandingForUser, and getBrandingForTeam
- Test organization branding override scenarios for both team and personal events
- Test fallback behavior when organization branding is not set
- Test handling of null and optional branding properties
- Verify theme and color inheritance from parent organizations

Co-Authored-By: [email protected] <[email protected]>

* test: Simplify branding tests to focus on core scenarios

- Reduce from 20 to 8 essential test cases
- Focus on org override and fallback behavior for each utility
- Remove redundant edge case tests while maintaining full coverage

Co-Authored-By: [email protected] <[email protected]>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: [email protected] <[email protected]>
This commit is contained in:
Anik Dhabal Babu
2025-10-17 14:03:50 -03:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> [email protected] <[email protected]>
parent 9d4522825b
commit fc4ecf500a
10 changed files with 362 additions and 20 deletions
@@ -83,9 +83,12 @@ export const getServerSideProps = async function getServerSideProps(
props: {
isEmbed,
profile: {
theme: form.user.theme,
brandColor: form.user.brandColor,
darkBrandColor: form.user.darkBrandColor,
theme: formWithUserProfile.user.profile?.organization?.theme ?? formWithUserProfile.user.theme,
brandColor:
formWithUserProfile.user.profile?.organization?.brandColor ?? formWithUserProfile.user.brandColor,
darkBrandColor:
formWithUserProfile.user.profile?.organization?.darkBrandColor ??
formWithUserProfile.user.darkBrandColor,
},
form: await getSerializableForm({ form: enrichFormWithMigrationData(formWithUserProfile) }),
},
+15 -3
View File
@@ -49,6 +49,13 @@ export const getEventTypesFromDB = async (id: number) => {
profile: {
select: {
organizationId: true,
organization: {
select: {
brandColor: true,
darkBrandColor: true,
theme: true,
},
},
},
},
teamId: true,
@@ -71,9 +78,15 @@ export const getEventTypesFromDB = async (id: number) => {
slug: true,
name: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
parent: {
select: {
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
},
},
createdByOAuthClientId: true,
@@ -116,12 +129,11 @@ export const getEventTypesFromDB = async (id: number) => {
}
const metadata = EventTypeMetaDataSchema.parse(eventType.metadata);
const { profile, ...restEventType } = eventType;
const isOrgTeamEvent = !!eventType?.team && !!profile?.organizationId;
const isOrgTeamEvent = !!eventType?.team && !!eventType.profile?.organizationId;
return {
isDynamic: false,
...restEventType,
...eventType,
bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }),
metadata,
};
@@ -7,8 +7,9 @@ import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booki
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getBrandingForEventType } from "@calcom/features/profile/lib/getBranding";
import { shouldHideBrandingForTeamEvent } from "@calcom/features/profile/lib/hideBranding";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
import type { User } from "@calcom/prisma/client";
@@ -30,7 +31,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const { req, params, query } = context;
const session = await getServerSession({ req });
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting, email } = query;
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting } = query;
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
@@ -133,6 +134,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
const branding = getBrandingForEventType({
eventType: {
team: team.parent ?? team,
users: [],
profile: null,
},
});
return {
props: {
useApiV2,
@@ -153,6 +162,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
: getPlaceholderAvatar(team.logoUrl, team.name),
name,
username: orgSlug ?? null,
...branding,
},
title: eventData.title,
users: eventHostsUserData,
@@ -204,6 +214,9 @@ const getTeamWithEventsData = async (
bannerUrl: true,
logoUrl: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
organizationSettings: {
select: {
allowSEOIndexing: true,
@@ -214,6 +227,9 @@ const getTeamWithEventsData = async (
logoUrl: true,
name: true,
slug: true,
brandColor: true,
darkBrandColor: true,
theme: true,
eventTypes: {
where: {
slug: meetingSlug,
@@ -6,12 +6,13 @@ import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-util
import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
import { getBrandingForEventType } from "@calcom/features/profile/lib/getBranding";
import { shouldHideBrandingForEvent } from "@calcom/features/profile/lib/hideBranding";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import prisma from "@calcom/prisma";
import { customInputSchema } from "@calcom/prisma/zod-utils";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
@@ -115,7 +116,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date;
bookingInfo["endTime"] = (bookingInfo?.endTime as Date)?.toISOString() as unknown as Date;
eventTypeRaw.users = !!eventTypeRaw.hosts?.length
eventTypeRaw.users = eventTypeRaw.hosts?.length
? eventTypeRaw.hosts.map((host) => host.user)
: eventTypeRaw.users;
@@ -150,9 +151,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const profile = {
name: eventType.team?.name || eventType.users[0]?.name || null,
email: eventType.team ? null : eventType.users[0].email || null,
theme: (!eventType.team?.name && eventType.users[0]?.theme) || null,
brandColor: eventType.team ? null : eventType.users[0].brandColor || null,
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor || null,
...getBrandingForEventType({ eventType: eventTypeRaw }),
slug: eventType.team?.slug || eventType.users[0]?.username || null,
};
@@ -20,7 +20,14 @@ function mockedUserPageComponentProps(props: Partial<React.ComponentProps<typeof
theme: "dark",
brandColor: "red",
darkBrandColor: "black",
organization: { requestedSlug: "slug", slug: "slug", id: 1 },
organization: {
requestedSlug: "slug",
slug: "slug",
id: 1,
brandColor: null,
darkBrandColor: null,
theme: null,
},
allowSEOIndexing: true,
username: "john",
},
@@ -4,14 +4,15 @@ import { encode } from "querystring";
import type { z } from "zod";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getEventTypesPublic } from "@calcom/features/eventtypes/lib/getEventTypesPublic";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
import { getEventTypesPublic } from "@calcom/features/eventtypes/lib/getEventTypesPublic";
import { getBrandingForUser } from "@calcom/features/profile/lib/getBranding";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import { prisma } from "@calcom/prisma";
import type { EventType, User } from "@calcom/prisma/client";
@@ -33,6 +34,9 @@ type UserPageProps = {
requestedSlug: string | null;
slug: string | null;
id: number | null;
brandColor: string | null;
darkBrandColor: string | null;
theme: string | null;
} | null;
allowSEOIndexing: boolean;
username: string | null;
@@ -130,15 +134,18 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
const [user] = usersInOrgContext; //to be used when dealing with single user, not dynamic group
const branding = getBrandingForUser({ user });
const profile = {
name: user.name || user.username || "",
image: getUserAvatarUrl({
avatarUrl: user.avatarUrl,
}),
theme: user.theme,
brandColor: user.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
theme: branding.theme,
brandColor: branding.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR,
avatarUrl: user.avatarUrl,
darkBrandColor: user.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
darkBrandColor:
branding.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR,
allowSEOIndexing: user.allowSEOIndexing ?? true,
username: user.username,
organization: user.profile.organization,
@@ -141,6 +141,7 @@ const commons = {
restrictionScheduleId: null,
useBookerTimezone: false,
profileId: null,
profile: null,
requiresConfirmationWillBlockSlot: false,
canSendCalVideoTranscriptionEmails: false,
instantMeetingExpiryTimeOffsetInSeconds: 0,
@@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
import { getBrandingForEventType, getBrandingForUser, getBrandingForTeam } from "./getBranding";
describe("getBranding", () => {
describe("getBrandingForEventType", () => {
describe("team events", () => {
it("should use parent org branding when available", () => {
const eventType = {
team: {
name: "Team A",
brandColor: "#AAAAAA",
darkBrandColor: "#BBBBBB",
theme: "light",
parent: {
brandColor: "#111111",
darkBrandColor: "#222222",
theme: "dark",
},
},
users: [],
};
const result = getBrandingForEventType({ eventType });
expect(result).toEqual({
theme: "dark",
brandColor: "#111111",
darkBrandColor: "#222222",
});
});
it("should fallback to team branding when no parent", () => {
const eventType = {
team: {
name: "Team A",
brandColor: "#AAAAAA",
darkBrandColor: "#BBBBBB",
theme: "light",
parent: null,
},
users: [],
};
const result = getBrandingForEventType({ eventType });
expect(result).toEqual({
theme: "light",
brandColor: "#AAAAAA",
darkBrandColor: "#BBBBBB",
});
});
});
describe("personal events", () => {
it("should use organization branding when available", () => {
const eventType = {
team: null,
profile: {
organization: {
brandColor: "#111111",
darkBrandColor: "#222222",
theme: "dark",
},
},
users: [
{
theme: "light",
brandColor: "#AAAAAA",
darkBrandColor: "#BBBBBB",
},
],
};
const result = getBrandingForEventType({ eventType });
expect(result).toEqual({
theme: "dark",
brandColor: "#111111",
darkBrandColor: "#222222",
});
});
it("should fallback to user branding when no organization", () => {
const eventType = {
team: null,
profile: {
organization: null,
},
users: [
{
theme: "light",
brandColor: "#AAAAAA",
darkBrandColor: "#BBBBBB",
},
],
};
const result = getBrandingForEventType({ eventType });
expect(result).toEqual({
theme: "light",
brandColor: "#AAAAAA",
darkBrandColor: "#BBBBBB",
});
});
});
});
describe("getBrandingForUser", () => {
it("should use organization branding when available", () => {
const user = {
theme: "light",
brandColor: "#AAAAAA",
darkBrandColor: "#BBBBBB",
profile: {
organization: {
brandColor: "#111111",
darkBrandColor: "#222222",
theme: "dark",
},
},
};
const result = getBrandingForUser({ user });
expect(result).toEqual({
theme: "dark",
brandColor: "#111111",
darkBrandColor: "#222222",
});
});
it("should fallback to user branding when no organization", () => {
const user = {
theme: "light",
brandColor: "#AAAAAA",
darkBrandColor: "#BBBBBB",
profile: {
organization: null,
},
};
const result = getBrandingForUser({ user });
expect(result).toEqual({
theme: "light",
brandColor: "#AAAAAA",
darkBrandColor: "#BBBBBB",
});
});
});
describe("getBrandingForTeam", () => {
it("should use parent org branding when available", () => {
const team = {
brandColor: "#AAAAAA",
darkBrandColor: "#BBBBBB",
theme: "light",
parent: {
brandColor: "#111111",
darkBrandColor: "#222222",
theme: "dark",
},
};
const result = getBrandingForTeam({ team });
expect(result).toEqual({
theme: "dark",
brandColor: "#111111",
darkBrandColor: "#222222",
});
});
it("should fallback to team branding when no parent", () => {
const team = {
brandColor: "#AAAAAA",
darkBrandColor: "#BBBBBB",
theme: "light",
parent: null,
};
const result = getBrandingForTeam({ team });
expect(result).toEqual({
theme: "light",
brandColor: "#AAAAAA",
darkBrandColor: "#BBBBBB",
});
});
});
});
@@ -0,0 +1,98 @@
type EventTypeWithBranding = {
team?: {
name?: string;
brandColor?: string | null;
darkBrandColor?: string | null;
theme?: string | null;
parent?: {
brandColor?: string | null;
darkBrandColor?: string | null;
theme?: string | null;
} | null;
} | null;
profile?: {
organization?: {
brandColor?: string | null;
darkBrandColor?: string | null;
theme?: string | null;
} | null;
} | null;
users: Array<{
theme?: string | null;
brandColor?: string | null;
darkBrandColor?: string | null;
}>;
};
type UserWithBranding = {
theme?: string | null;
brandColor?: string | null;
darkBrandColor?: string | null;
profile: {
organization?: {
brandColor?: string | null;
darkBrandColor?: string | null;
theme?: string | null;
} | null;
};
};
type TeamWithBranding = {
brandColor?: string | null;
darkBrandColor?: string | null;
theme?: string | null;
parent?: {
brandColor?: string | null;
darkBrandColor?: string | null;
theme?: string | null;
} | null;
};
type BrandingResult = {
theme: string | null;
brandColor: string | null;
darkBrandColor: string | null;
};
export function getBrandingForEventType(params: { eventType: EventTypeWithBranding }): BrandingResult {
const { eventType } = params;
if (eventType.team) {
const brandColorData =
eventType.team.parent?.brandColor || eventType.team.parent?.darkBrandColor
? eventType.team.parent
: eventType.team;
return {
theme: eventType.team.parent?.theme ?? eventType.team.theme ?? null,
brandColor: brandColorData.brandColor ?? null,
darkBrandColor: brandColorData.darkBrandColor ?? null,
};
}
const branding = eventType.profile?.organization ?? eventType.users[0];
return {
theme: branding?.theme ?? null,
brandColor: branding?.brandColor ?? null,
darkBrandColor: branding?.darkBrandColor ?? null,
};
}
export function getBrandingForUser(params: { user: UserWithBranding }): BrandingResult {
const { user } = params;
const branding = user.profile.organization ?? user;
return {
theme: branding.theme ?? null,
brandColor: branding.brandColor ?? null,
darkBrandColor: branding.darkBrandColor ?? null,
};
}
export function getBrandingForTeam(params: { team: TeamWithBranding }): BrandingResult {
const { team } = params;
const brandColorData = team.parent?.brandColor || team.parent?.darkBrandColor ? team.parent : team;
return {
theme: team.parent?.theme ?? team.theme ?? null,
brandColor: brandColorData.brandColor ?? null,
darkBrandColor: brandColorData.darkBrandColor ?? null,
};
}
@@ -50,6 +50,9 @@ const organizationSelect = {
bannerUrl: true,
isPlatform: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
};
const organizationWithSettingsSelect = {
...organizationSelect,
@@ -496,6 +499,9 @@ export class ProfileRepository {
isPrivate: true,
isPlatform: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
organizationSettings: {
select: {
lockEventTypeCreationForUsers: true,