chore: salesforce for Router / Booker atoms and api v2 (#19217)

* wip

* small refactor

* small refactor

* fixup! Merge branch 'main' into enable-salesforce-router-apiv2-atom

* crm props

* fixup! crm props

* bump platform libraries

* bump platform libraries
This commit is contained in:
Morgan
2025-02-12 15:04:38 +02:00
committed by GitHub
parent 3a29327761
commit 486e3a386e
14 changed files with 241 additions and 122 deletions
+1 -1
View File
@@ -30,7 +30,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.95",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.99",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -169,4 +169,19 @@ export class CreateBookingInput_2024_04_15 {
label?: string | undefined;
}
>;
@IsString()
@IsOptional()
@ApiPropertyOptional()
teamMemberEmail?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
crmAppSlug?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
crmOwnerRecordType?: string;
}
@@ -1,9 +1,13 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { Controller, Req, NotFoundException, Param, Post, Body } from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { Request } from "express";
import { getRoutedUrl } from "@calcom/platform-libraries";
import {
getRoutedUrl,
getTeamMemberEmailForResponseOrContactUsingUrlQuery,
} from "@calcom/platform-libraries";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
@@ -13,6 +17,8 @@ import { ApiResponse } from "@calcom/platform-types";
@DocsTags("Router controller")
@DocsExcludeController(true)
export class RouterController {
constructor(private readonly teamsEventTypesRepository: TeamsEventTypesRepository) {}
@Post("/forms/:formId/submit")
async getRoutingFormResponse(
@Req() request: Request,
@@ -27,13 +33,54 @@ export class RouterController {
}
if (routedUrlData?.redirect?.destination) {
return { status: "success", data: routedUrlData?.redirect?.destination, redirect: true };
return this.handleRedirect(routedUrlData.redirect.destination);
}
if (routedUrlData?.props) {
return { status: "success", data: { message: routedUrlData?.props?.message ?? "" }, redirect: false };
return { status: "success", data: { message: routedUrlData.props.message ?? "" }, redirect: false };
}
return { status: "success", data: { message: "No Route nor custom message found." }, redirect: false };
}
private async handleRedirect(destination: string): Promise<ApiResponse<unknown> & { redirect: boolean }> {
const routingUrl = new URL(destination);
const routingSearchParams = routingUrl.searchParams;
if (
routingSearchParams.get("cal.action") === "eventTypeRedirectUrl" &&
routingSearchParams.has("email") &&
routingSearchParams.has("cal.teamId") &&
!routingSearchParams.has("cal.skipContactOwner")
) {
return this.handleRedirectWithContactOwner(routingUrl, routingSearchParams);
}
return { status: "success", data: destination, redirect: true };
}
private async handleRedirectWithContactOwner(
routingUrl: URL,
routingSearchParams: URLSearchParams
): Promise<ApiResponse<unknown> & { redirect: boolean }> {
const pathNameParams = routingUrl.pathname.split("/");
const eventTypeSlug = pathNameParams[pathNameParams.length - 1];
const teamId = Number(routingSearchParams.get("cal.teamId"));
const eventTypeData = this.teamsEventTypesRepository.getTeamEventTypeBySlug(teamId, eventTypeSlug, 3);
// get the salesforce record owner email for the email given as a form response.
const {
email: teamMemberEmail,
recordType: crmOwnerRecordType,
crmAppSlug,
} = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: Object.fromEntries(routingSearchParams),
eventData: eventTypeData,
});
Boolean(teamMemberEmail) && routingUrl.searchParams.set("cal.teamMemberEmail", teamMemberEmail);
Boolean(crmOwnerRecordType) && routingUrl.searchParams.set("cal.crmOwnerRecordType", crmOwnerRecordType);
Boolean(crmAppSlug) && routingUrl.searchParams.set("cal.crmAppSlug", crmAppSlug);
return { status: "success", data: routingUrl.toString(), redirect: true };
}
}
@@ -1,10 +1,11 @@
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RouterController } from "@/modules/router/controllers/router.controller";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [],
providers: [TeamsEventTypesRepository],
exports: [],
controllers: [RouterController],
})
+8
View File
@@ -5593,6 +5593,14 @@
],
"type": "string"
}
},
{
"name": "teamMemberEmail",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
@@ -2,13 +2,13 @@ import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import type { User } from "@calcom/prisma/client";
import { RedirectType } from "@calcom/prisma/client";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -21,9 +21,6 @@ const paramsSchema = z.object({
slug: z.string().transform((s) => slugify(s)),
});
// Booker page fetches a tiny bit of data server side:
// 1. Check if team exists, to show 404
// 2. If rescheduling, get the booking details
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { req, params, query } = context;
const session = await getServerSession({ req });
@@ -45,7 +42,90 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
}
}
const team = await prisma.team.findFirst({
const team = await getTeamWithEventsData(teamSlug, meetingSlug, isValidOrgDomain, currentOrgDomain);
if (!team || !team.eventTypes?.[0]) {
return { notFound: true } as const;
}
const eventData = team.eventTypes[0];
const eventTypeId = eventData.id;
const eventHostsUserData = await getUsersData(
team.isPrivate,
eventTypeId,
eventData.hosts.map((h) => h.user)
);
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
const name = team.parent?.name ?? team.name ?? null;
const booking = rescheduleUid ? await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id) : null;
const ssr = await ssrInit(context);
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
"@calcom/lib/server/getTeamMemberEmailFromCrm"
);
const {
email: teamMemberEmail,
recordType: crmOwnerRecordType,
crmAppSlug,
} = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query,
eventData,
});
const organizationSettings = getOrganizationSEOSettings(team);
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
return {
props: {
eventData: {
eventTypeId,
entity: {
fromRedirectOfNonOrgLink,
considerUnpublished: isUnpublished && !fromRedirectOfNonOrgLink,
orgSlug,
teamSlug: team.slug ?? null,
name,
},
length: eventData.length,
metadata: EventTypeMetaDataSchema.parse(eventData.metadata),
profile: {
image: team.parent
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
: getPlaceholderAvatar(team.logoUrl, team.name),
name,
username: orgSlug ?? null,
},
title: eventData.title,
users: eventHostsUserData,
hidden: eventData.hidden,
},
booking,
user: teamSlug,
teamId: team.id,
slug: meetingSlug,
trpcState: ssr.dehydrate(),
isBrandingHidden: team?.hideBranding,
isInstantMeeting: eventData && queryIsInstantMeeting ? true : false,
themeBasis: null,
orgBannerUrl: team.parent?.bannerUrl ?? "",
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
isSEOIndexable: allowSEOIndexing,
},
};
};
const getTeamWithEventsData = async (
teamSlug: string,
meetingSlug: string,
isValidOrgDomain: boolean,
currentOrgDomain: string | null
) => {
return await prisma.team.findFirst({
where: {
...getSlugOrRequestedSlug(teamSlug),
parent: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
@@ -107,32 +187,22 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
},
});
};
if (!team || !team.eventTypes?.[0]) {
return {
notFound: true,
} as const;
}
const eventData = team.eventTypes[0];
const eventTypeId = eventData.id;
// INFO: This code was pulled from getPublicEvent and used here.
// Calling the tRPC fetch to get the public event data is incredibly slow
// for large teams and we don't want to add it back. Future refactors will happen
// to speed up this call.
let users: { username: string; name: string }[] = [];
if (!team.isPrivate && eventData.hosts.length > 0) {
users = eventData.hosts
.filter((host) => host.user.username)
.map((host) => ({
username: host.user.username ?? "",
name: host.user.name ?? "",
const getUsersData = async (
isPrivateTeam: boolean,
eventTypeId: number,
users: Pick<User, "username" | "name">[]
) => {
if (!isPrivateTeam && users.length > 0) {
return users
.filter((user) => user.username)
.map((user) => ({
username: user.username ?? "",
name: user.name ?? "",
}));
}
if (!team.isPrivate && eventData.hosts.length === 0) {
// a minimalistic version of `getOwnerFromUsersArray` in `getPublicEvent.ts`
// backward compatibility logic for team event types that have users[] but not hosts[]
if (!isPrivateTeam && users.length === 0) {
const { users: data } = await prisma.eventType.findUniqueOrThrow({
where: { id: eventTypeId },
select: {
@@ -146,85 +216,15 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
});
if (data.length > 0) {
users = [
{
username: data[0].username ?? "",
name: data[0].name ?? "",
},
];
}
return data.length > 0
? [
{
username: data[0].username ?? "",
name: data[0].name ?? "",
},
]
: [];
}
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
const name = team.parent?.name ?? team.name ?? null;
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
}
const ssr = await ssrInit(context);
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
"@calcom/lib/server/getTeamMemberEmailFromCrm"
);
const {
email: teamMemberEmail,
recordType: crmOwnerRecordType,
crmAppSlug,
} = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query,
eventData,
});
const organizationSettings = getOrganizationSEOSettings(team);
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
if (!eventData) {
return {
notFound: true,
} as const;
}
return {
props: {
eventData: {
eventTypeId,
entity: {
fromRedirectOfNonOrgLink,
considerUnpublished: isUnpublished && !fromRedirectOfNonOrgLink,
orgSlug,
teamSlug: team.slug ?? null,
name,
},
length: eventData.length,
metadata: EventTypeMetaDataSchema.parse(eventData.metadata),
profile: {
image: team.parent
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
: getPlaceholderAvatar(team.logoUrl, team.name),
name,
username: orgSlug ?? null,
},
title: eventData.title,
users,
hidden: eventData.hidden,
},
booking,
user: teamSlug,
teamId: team.id,
slug: meetingSlug,
trpcState: ssr.dehydrate(),
isBrandingHidden: team?.hideBranding,
isInstantMeeting: eventData && queryIsInstantMeeting ? true : false,
themeBasis: null,
orgBannerUrl: team.parent?.bannerUrl ?? "",
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
isSEOIndexable: allowSEOIndexing,
},
};
return [];
};
+15 -7
View File
@@ -5380,6 +5380,14 @@
"enum": ["range", "time"],
"type": "string"
}
},
{
"name": "teamMemberEmail",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
@@ -7338,7 +7346,7 @@
},
"disableOnPrefill": {
"type": "boolean",
"description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `language` and options of this select field are ['english', 'italian'] and the URL contains query parameter `&language=italian`, the 'italian' radio button will be selected and the select field will be disabled."
"description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `language` and options of this select field are ['english', 'italian'] and the URL contains query parameter `&language=italian`, the 'italian' radio buttom will be selected and the select field will be disabled."
},
"hidden": {
"type": "boolean",
@@ -7946,7 +7954,7 @@
},
"beforeEventBuffer": {
"type": "number",
"description": "Time spaces that can be prepended before an event to give more time before it."
"description": "Time spaces that can be pre-pended before an event to give more time before it."
},
"afterEventBuffer": {
"type": "number",
@@ -8997,7 +9005,7 @@
},
"disableOnPrefill": {
"type": "boolean",
"description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `language` and options of this select field are ['english', 'italian'] and the URL contains query parameter `&language=italian`, the 'italian' radio button will be selected and the select field will be disabled."
"description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `language` and options of this select field are ['english', 'italian'] and the URL contains query parameter `&language=italian`, the 'italian' radio buttom will be selected and the select field will be disabled."
},
"hidden": {
"type": "boolean",
@@ -9638,7 +9646,7 @@
},
"beforeEventBuffer": {
"type": "number",
"description": "Time spaces that can be prepended before an event to give more time before it."
"description": "Time spaces that can be pre-pended before an event to give more time before it."
},
"afterEventBuffer": {
"type": "number",
@@ -10992,7 +11000,7 @@
},
"beforeEventBuffer": {
"type": "number",
"description": "Time spaces that can be prepended before an event to give more time before it."
"description": "Time spaces that can be pre-pended before an event to give more time before it."
},
"afterEventBuffer": {
"type": "number",
@@ -11763,7 +11771,7 @@
},
"beforeEventBuffer": {
"type": "number",
"description": "Time spaces that can be prepended before an event to give more time before it."
"description": "Time spaces that can be pre-pended before an event to give more time before it."
},
"afterEventBuffer": {
"type": "number",
@@ -15366,7 +15374,7 @@
"externalId": {
"type": "string",
"example": "https://caldav.icloud.com/26962146906/calendars/1644422A-1945-4438-BBC0-4F0Q23A57R7S/",
"description": "Unique identifier used to represent the specific calendar, as returned by the /calendars endpoint"
"description": "Unique identifier used to represent the specfic calendar, as returned by the /calendars endpoint"
}
},
"required": ["integration", "externalId"]
@@ -15,7 +15,7 @@ import isRouter from "../lib/isRouter";
import { onFormSubmission } from "../trpc/utils";
import type { FormResponse, SerializableForm } from "../types/types";
type Form = SerializableForm<
export type Form = SerializableForm<
App_RoutingForms_Form & {
user: {
id: number;
@@ -41,6 +41,9 @@ export const BookerEmbed = (
eventTypeSlug,
username,
defaultFormValues,
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
...routingFormSearchParams
} = routingFormUrlProps;
const { onDryRunSuccess, ...rest } = props;
@@ -67,6 +70,9 @@ export const BookerEmbed = (
})}
routingFormSearchParams={routingFormSearchParams}
defaultFormValues={defaultFormValues}
teamMemberEmail={teamMemberEmail}
crmOwnerRecordType={crmOwnerRecordType}
crmAppSlug={crmAppSlug}
onDryRunSuccess={() => {
if (onDryRunSuccess) {
onDryRunSuccess();
@@ -81,6 +81,15 @@ export const useGetRoutingFormUrlProps = ({ routingFormUrl }: { routingFormUrl?:
["cal.salesforce.rrSkipToAccountLookupField"]:
routingSearchParams.get("cal.salesforce.rrSkipToAccountLookupField") ?? undefined,
}),
...(routingSearchParams.get("cal.teamMemberEmail") && {
teamMemberEmail: routingSearchParams.get("cal.teamMemberEmail") ?? undefined,
}),
...(routingSearchParams.get("cal.crmOwnerRecordType") && {
crmOwnerRecordType: routingSearchParams.get("cal.crmOwnerRecordType") ?? undefined,
}),
...(routingSearchParams.get("cal.crmAppSlug") && {
crmAppSlug: routingSearchParams.get("cal.crmAppSlug") ?? undefined,
}),
} satisfies RoutingFormSearchParamsForEmbed;
return { ...routingformProps, defaultFormValues };
}
@@ -102,7 +102,14 @@ export type BookerPlatformWrapperAtomPropsForTeam = BookerPlatformWrapperAtomPro
export const BookerPlatformWrapper = (
props: BookerPlatformWrapperAtomPropsForIndividual | BookerPlatformWrapperAtomPropsForTeam
) => {
const { view = "MONTH_VIEW", bannerUrl, routingFormSearchParams } = props;
const {
view = "MONTH_VIEW",
bannerUrl,
routingFormSearchParams,
teamMemberEmail,
crmAppSlug,
crmOwnerRecordType,
} = props;
const layout = BookerLayouts[view];
const { clientId } = useAtomsContext();
@@ -205,6 +212,9 @@ export const BookerPlatformWrapper = (
const bookerLayout = useBookerLayout(event.data);
useInitializeBookerStore({
...props,
teamMemberEmail,
crmAppSlug,
crmOwnerRecordType,
eventId: event.data?.id,
rescheduleUid: props.rescheduleUid ?? null,
bookingUid: props.bookingUid ?? null,
@@ -296,7 +306,6 @@ export const BookerPlatformWrapper = (
...(isBookingDryRun ? { isBookingDryRun } : {}),
});
}, [routingFormSearchParams]);
const schedule = useAvailableSlots({
usernameList: getUsernameList(username),
eventTypeId: event?.data?.id ?? 0,
@@ -305,6 +314,7 @@ export const BookerPlatformWrapper = (
timeZone: timezone,
duration: selectedDuration ?? undefined,
rescheduleUid: props.rescheduleUid,
teamMemberEmail: teamMemberEmail ?? undefined,
...(props.isTeamEvent
? {
isTeamEvent: props.isTeamEvent,
@@ -476,6 +486,9 @@ export const BookerPlatformWrapper = (
return (
<AtomsWrapper customClassName={props?.customClassNames?.atomsWrapper}>
<BookerComponent
teamMemberEmail={teamMemberEmail}
crmAppSlug={crmAppSlug}
crmOwnerRecordType={crmOwnerRecordType}
customClassNames={props.customClassNames}
eventSlug={props.eventSlug}
username={username}
@@ -25,6 +25,7 @@ export const useAvailableSlots = ({
rest.routedTeamMemberIds,
rest.skipContactOwner,
rest.shouldServeCache,
rest.teamMemberEmail,
],
queryFn: () => {
return http
+6
View File
@@ -3,6 +3,9 @@ export type RoutingFormSearchParamsForEmbed = {
teamId?: number;
eventTypeSlug: string;
username?: string;
crmAppSlug?: string;
crmOwnerRecordType?: string;
teamMemberEmail?: string;
} & RoutingFormSearchParams;
export type RoutingFormSearchParams = {
@@ -12,5 +15,8 @@ export type RoutingFormSearchParams = {
["cal.isBookingDryRun"]?: string;
["cal.cache"]?: string;
["cal.routingFormResponseId"]?: string;
["cal.crmAppSlug"]?: string;
["cal.crmOwnerRecordType"]?: string;
["cal.teamMemberEmail"]?: string;
["cal.salesforce.rrSkipToAccountLookupField"]?: string;
};
+5
View File
@@ -124,6 +124,11 @@ export class GetAvailableSlotsInput {
@IsNumber({}, { each: true })
@ApiHideProperty()
routedTeamMemberIds?: number[];
@IsString()
@IsOptional()
@ApiPropertyOptional()
teamMemberEmail?: string;
}
export class RemoveSelectedSlotInput {