diff --git a/packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts b/packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts index 4390024ab7..3b4425688c 100644 --- a/packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts +++ b/packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts @@ -14,18 +14,15 @@ import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/avail import { WEBAPP_URL } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { uploadLogo } from "@calcom/lib/server/avatar"; import { getTranslation } from "@calcom/lib/server/i18n"; import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding"; +import { isBase64Image, resizeBase64Image } from "@calcom/lib/server/resizeBase64Image"; import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; import type { Prisma, Team, User } from "@calcom/prisma/client"; import { CreationSource, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums"; -import { - userMetadata, - orgOnboardingInvitedMembersSchema, - orgOnboardingTeamsSchema, - teamMetadataStrictSchema, -} from "@calcom/prisma/zod-utils"; +import { userMetadata, teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler"; import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler"; @@ -45,10 +42,6 @@ import type { } from "./types"; const log = logger.getSubLogger({ prefix: ["BaseOnboardingService"] }); -const invitedMembersSchema = orgOnboardingInvitedMembersSchema; -const teamsSchema = orgOnboardingTeamsSchema; - -type OrgOwner = Awaited>; export abstract class BaseOnboardingService implements IOrganizationOnboardingService { protected user: OnboardingUser; @@ -65,14 +58,18 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe this.permissionService = permissionService || new OrganizationPermissionService(user); } - abstract createOnboardingIntent(input: CreateOnboardingIntentInput): Promise; + abstract createOnboardingIntent(input: CreateOnboardingIntentInput): Promise; abstract createOrganization( organizationOnboarding: OrganizationOnboardingData, paymentDetails?: { subscriptionId: string; subscriptionItemId: string } ): Promise<{ organization: Team; owner: User }>; protected async createOnboardingRecord(input: CreateOnboardingIntentInput & { onboardingId?: string }) { - // If onboardingId exists, update the existing record (resume flow) + const processedAssets = await this.processOnboardingBrandAssets({ + logo: input.logo, + bannerUrl: input.bannerUrl, + }); + if (input.onboardingId) { log.debug( "Updating existing organization onboarding record (resume flow)", @@ -83,14 +80,28 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe }) ); - await OrganizationOnboardingRepository.update(input.onboardingId, { - logo: input.logo ?? null, + const updateData: { + logo?: string | null; + bio?: string | null; + brandColor?: string | null; + bannerUrl?: string | null; + teams?: TeamData[]; + invitedMembers?: InvitedMember[]; + } = { bio: input.bio ?? null, brandColor: input.brandColor ?? null, - bannerUrl: input.bannerUrl ?? null, teams: input.teams ?? [], invitedMembers: input.invitedMembers ?? [], - }); + }; + + if (processedAssets.logo !== undefined) { + updateData.logo = processedAssets.logo; + } + if (processedAssets.bannerUrl !== undefined) { + updateData.bannerUrl = processedAssets.bannerUrl; + } + + await OrganizationOnboardingRepository.update(input.onboardingId, updateData); const updatedOnboarding = await OrganizationOnboardingRepository.findById(input.onboardingId); if (!updatedOnboarding) { @@ -101,7 +112,6 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe return updatedOnboarding; } - // Create new onboarding record log.debug( "Creating organization onboarding record", safeStringify({ @@ -120,10 +130,10 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe pricePerSeat: input.pricePerSeat, billingPeriod: input.billingPeriod, createdByUserId: this.user.id, - logo: input.logo ?? null, + logo: processedAssets.logo ?? null, bio: input.bio ?? null, brandColor: input.brandColor ?? null, - bannerUrl: input.bannerUrl ?? null, + bannerUrl: processedAssets.bannerUrl ?? null, teams: input.teams ?? [], invitedMembers: input.invitedMembers ?? [], }); @@ -209,6 +219,76 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe return this.user.role === UserPermissionRole.ADMIN && this.user.email === input.orgOwnerEmail; } + private async processOnboardingBrandAssets(input: { + logo?: string | null; + bannerUrl?: string | null; + }): Promise<{ logo?: string | null; bannerUrl?: string | null }> { + const [logo, bannerUrl] = await Promise.all([ + input.logo ? resizeBase64Image(input.logo) : Promise.resolve(input.logo), + input.bannerUrl ? resizeBase64Image(input.bannerUrl, { maxSize: 1500 }) : Promise.resolve(input.bannerUrl), + ]); + + return { logo, bannerUrl }; + } + + private async uploadImageAsset({ + image, + teamId, + isBanner = false, + }: { + image: string; + teamId: number; + isBanner?: boolean; + }): Promise { + if (!isBase64Image(image)) { + return image; + } + + return await uploadLogo({ + logo: image, + teamId, + isBanner, + }); + } + + protected async uploadOrganizationBrandAssets({ + logoUrl, + bannerUrl, + organizationId, + }: { + logoUrl?: string | null; + bannerUrl?: string | null; + organizationId: number; + }): Promise<{ + logoUrl?: string | null; + bannerUrl?: string | null; + }> { + const uploadedLogoUrl = !!logoUrl ? await this.uploadImageAsset({ image: logoUrl, teamId: organizationId }) : logoUrl; + + const uploadedBannerUrl = !!bannerUrl + ? await this.uploadImageAsset({ image: bannerUrl, teamId: organizationId, isBanner: true }) + : bannerUrl; + + if (uploadedLogoUrl === undefined && uploadedBannerUrl === undefined) { + return { + logoUrl, + bannerUrl, + }; + } + + return await prisma.team.update({ + where: { id: organizationId }, + data: { + logoUrl: uploadedLogoUrl, + bannerUrl: uploadedBannerUrl, + }, + select: { + logoUrl: true, + bannerUrl: true, + }, + }); + } + protected async createOrganizationWithExistingUserAsOwner({ owner, orgData, @@ -246,9 +326,14 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe try { const nonOrgUsername = owner.username || ""; + + // Create organization first to get the ID const orgCreationResult = await OrganizationRepository.createWithExistingUserAsOwner({ orgData: { ...orgData, + // Don't pass brand assets yet - will be uploaded after org is created + logoUrl: null, + bannerUrl: null, ...(canSetSlug ? { slug: orgData.slug } : { slug: null, requestedSlug: orgData.slug }), }, owner: { @@ -257,9 +342,17 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe nonOrgUsername, }, }); - organization = orgCreationResult.organization; - const ownerProfile = orgCreationResult.ownerProfile; + organization = { + ...orgCreationResult.organization, + ...await this.uploadOrganizationBrandAssets({ + logoUrl: orgData.logoUrl, + bannerUrl: orgData.bannerUrl, + organizationId: orgCreationResult.organization.id, + }) + }; + + const ownerProfile = orgCreationResult.ownerProfile; if (!orgData.isPlatform) { await sendOrganizationCreationEmail({ language: orgOwnerTranslation, @@ -318,13 +411,27 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe } const orgCreationResult = await OrganizationRepository.createWithNonExistentOwner({ - orgData, + orgData: { + ...orgData, + // To be uploaded after org is created + logoUrl: null, + bannerUrl: null, + }, owner: { email: email, }, creationSource: CreationSource.WEBAPP, }); - organization = orgCreationResult.organization; + + organization = { + ...orgCreationResult.organization, + ...await this.uploadOrganizationBrandAssets({ + logoUrl: orgData.logoUrl, + bannerUrl: orgData.bannerUrl, + organizationId: orgCreationResult.organization.id, + }) + }; + const { ownerProfile, orgOwner: orgOwnerFromCreation } = orgCreationResult; const orgOwner = await findUserToBeOrgOwner(orgOwnerFromCreation.email); if (!orgOwner) { @@ -462,8 +569,7 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe } else if (member.teamName) { targetTeamId = teamNameToId.get(member.teamName.toLowerCase()); log.debug( - `Member ${member.email}: teamName "${member.teamName}" -> resolved to ${ - targetTeamId || "not found" + `Member ${member.email}: teamName "${member.teamName}" -> resolved to ${targetTeamId || "not found" }` ); } @@ -603,3 +709,4 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe }); } } + diff --git a/packages/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService.ts b/packages/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService.ts index a8fc3bf6a1..daec8c3b1d 100644 --- a/packages/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService.ts +++ b/packages/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService.ts @@ -105,10 +105,10 @@ export class BillingEnabledOrgOnboardingService extends BaseOnboardingService { // Regular flow - create payment intent const paymentIntent = await this.paymentService.createPaymentIntent( { - logo: input.logo ?? null, + logo: organizationOnboarding.logo, bio: input.bio ?? null, brandColor: input.brandColor ?? null, - bannerUrl: input.bannerUrl ?? null, + bannerUrl: organizationOnboarding.bannerUrl, teams: teamsData, invitedMembers: invitedMembersData, }, diff --git a/packages/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService.ts b/packages/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService.ts index 468f9471f3..a021439076 100644 --- a/packages/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService.ts +++ b/packages/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService.ts @@ -78,10 +78,10 @@ export class SelfHostedOrganizationOnboardingService extends BaseOnboardingServi invitedMembers: invitedMembersData, teams: teamsData, isPlatform: input.isPlatform, - logo: input.logo ?? null, + logo: organizationOnboarding.logo, bio: input.bio ?? null, brandColor: input.brandColor ?? null, - bannerUrl: input.bannerUrl ?? null, + bannerUrl: organizationOnboarding.bannerUrl, stripeCustomerId: null, isDomainConfigured: false, }); diff --git a/packages/lib/server/resizeBase64Image.ts b/packages/lib/server/resizeBase64Image.ts index badba5118f..548b62c431 100644 --- a/packages/lib/server/resizeBase64Image.ts +++ b/packages/lib/server/resizeBase64Image.ts @@ -1,5 +1,9 @@ import jimp from "jimp"; +export function isBase64Image(value: string): boolean { + return /^data:image\/(png|jpe?g);base64,/i.test(value); +} + export async function resizeBase64Image( base64OrUrl: string, opts?: {