From fc4ecf500af901e071be3eb2690cecd52c2a8e6e Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Fri, 17 Oct 2025 22:33:50 +0530 Subject: [PATCH] fix: Apply organization brand colors and theme to member personal events (#24456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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: anik@cal.com * 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: anik@cal.com * 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: anik@cal.com * 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: anik@cal.com * 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: anik@cal.com * 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: hariom@cal.com * 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: anik@cal.com * 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: anik@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: hariom@cal.com --- .../getServerSidePropsRoutingLink.ts | 9 +- apps/web/lib/booking.ts | 18 +- .../team/[slug]/[type]/getServerSideProps.tsx | 20 +- ...ookings-single-view.getServerSideProps.tsx | 9 +- .../users/views/users-public-view.test.tsx | 9 +- .../server/lib/[user]/getServerSideProps.ts | 19 +- .../features/eventtypes/lib/defaultEvents.ts | 1 + .../features/profile/lib/getBranding.test.ts | 193 ++++++++++++++++++ packages/features/profile/lib/getBranding.ts | 98 +++++++++ .../profile/repositories/ProfileRepository.ts | 6 + 10 files changed, 362 insertions(+), 20 deletions(-) create mode 100644 packages/features/profile/lib/getBranding.test.ts create mode 100644 packages/features/profile/lib/getBranding.ts diff --git a/apps/web/lib/apps/routing-forms/[...pages]/getServerSidePropsRoutingLink.ts b/apps/web/lib/apps/routing-forms/[...pages]/getServerSidePropsRoutingLink.ts index 533c289366..ef03556418 100644 --- a/apps/web/lib/apps/routing-forms/[...pages]/getServerSidePropsRoutingLink.ts +++ b/apps/web/lib/apps/routing-forms/[...pages]/getServerSidePropsRoutingLink.ts @@ -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) }), }, diff --git a/apps/web/lib/booking.ts b/apps/web/lib/booking.ts index 3c61015f0f..d209dfafbb 100644 --- a/apps/web/lib/booking.ts +++ b/apps/web/lib/booking.ts @@ -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, }; diff --git a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx index 2d820eebae..fcd939cd87 100644 --- a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx +++ b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx @@ -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, diff --git a/apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx b/apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx index 64e6a6fdba..4223b5cf6c 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx @@ -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, }; diff --git a/apps/web/modules/users/views/users-public-view.test.tsx b/apps/web/modules/users/views/users-public-view.test.tsx index 466122ff75..407f2a6293 100644 --- a/apps/web/modules/users/views/users-public-view.test.tsx +++ b/apps/web/modules/users/views/users-public-view.test.tsx @@ -20,7 +20,14 @@ function mockedUserPageComponentProps(props: Partial = 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, diff --git a/packages/features/eventtypes/lib/defaultEvents.ts b/packages/features/eventtypes/lib/defaultEvents.ts index b50efc84f1..658c04e6e4 100644 --- a/packages/features/eventtypes/lib/defaultEvents.ts +++ b/packages/features/eventtypes/lib/defaultEvents.ts @@ -141,6 +141,7 @@ const commons = { restrictionScheduleId: null, useBookerTimezone: false, profileId: null, + profile: null, requiresConfirmationWillBlockSlot: false, canSendCalVideoTranscriptionEmails: false, instantMeetingExpiryTimeOffsetInSeconds: 0, diff --git a/packages/features/profile/lib/getBranding.test.ts b/packages/features/profile/lib/getBranding.test.ts new file mode 100644 index 0000000000..d904888ac5 --- /dev/null +++ b/packages/features/profile/lib/getBranding.test.ts @@ -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", + }); + }); + }); +}); diff --git a/packages/features/profile/lib/getBranding.ts b/packages/features/profile/lib/getBranding.ts new file mode 100644 index 0000000000..f8f0ca3225 --- /dev/null +++ b/packages/features/profile/lib/getBranding.ts @@ -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, + }; +} diff --git a/packages/features/profile/repositories/ProfileRepository.ts b/packages/features/profile/repositories/ProfileRepository.ts index f7193091be..4d3b6f2607 100644 --- a/packages/features/profile/repositories/ProfileRepository.ts +++ b/packages/features/profile/repositories/ProfileRepository.ts @@ -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,