diff --git a/apps/web/lib/apps/routing-forms/[...pages]/getServerSidePropsRoutingLink.ts b/apps/web/lib/apps/routing-forms/[...pages]/getServerSidePropsRoutingLink.ts index 533c2893661b94..ef035564185cda 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 3c61015f0fff52..d209dfafbb1d3a 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 2d820eebae2a79..fcd939cd874928 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 64e6a6fdbad7d9..4223b5cf6c0b30 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 466122ff75aff0..407f2a6293eae7 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 b50efc84f1b6d3..658c04e6e4e42f 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 00000000000000..d904888ac5309f --- /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 00000000000000..f8f0ca322544e5 --- /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 f7193091be0535..4d3b6f2607af81 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,