* feat: include record IDs in Salesforce assignment reason strings - Add recordId parameter to assignmentReasonHandler function - Include Contact ID, Lead ID, and Account ID in assignment reason strings - Update entire call chain to pass record IDs from CRM service - Maintain backward compatibility with optional recordId parameter Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: resolve lint warnings in assignment reason handler implementation - Change Record<string, any> to Record<string, unknown> in BookingHandlerInput type - Remove unused eventTypeId variable in getAttributeRoutingConfig function Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: revert to Record<string, any> with ESLint disable for BookingHandlerInput - Revert from Record<string, unknown> to Record<string, any> to maintain type compatibility - Add ESLint disable comment to suppress no-explicit-any warning - Maintains consistency with handleNewRecurringBooking.ts pattern Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * feat: pass CRM record ID from booker state to handleNewBooking - Add crmRecordId field to booker store interface and initialization - Update mapBookingToMutationInput to include record ID from booker state - Modify handleNewBooking to extract record ID from bookingData parameter - Add crmRecordId to BookingCreateBody schema in Prisma layer - Follow existing pattern for CRM fields (teamMemberEmail, crmOwnerRecordType, crmAppSlug) - Ensures record ID flows: booker store → booking form → mapBookingToMutationInput → handleNewBooking This replaces the previous backend CRM service extraction approach with frontend booker state approach as requested by the user. Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Pass crmRecordId as prop --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Alex van Andel <me@alexvanandel.com>
284 lines
8.3 KiB
TypeScript
284 lines
8.3 KiB
TypeScript
import type { ParsedUrlQuery } from "querystring";
|
|
|
|
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
|
|
import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants";
|
|
import { enabledAppSlugs } from "@calcom/app-store/routing-forms/lib/enabledApps";
|
|
import type { AttributeRoutingConfig, LocalRoute } from "@calcom/app-store/routing-forms/types/types";
|
|
import { zodRoutes as routesSchema } from "@calcom/app-store/routing-forms/zod";
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import prisma from "@calcom/prisma";
|
|
import type { Prisma } from "@calcom/prisma/client";
|
|
import { SchedulingType } from "@calcom/prisma/enums";
|
|
|
|
const log = logger.getSubLogger({ prefix: ["getTeamMemberEmailFromCrm"] });
|
|
|
|
interface EventData {
|
|
id: number;
|
|
isInstantEvent: boolean;
|
|
schedulingType: SchedulingType | null;
|
|
metadata: Prisma.JsonValue | null;
|
|
length: number;
|
|
}
|
|
|
|
const returnNullValue = { email: null, recordType: null, crmAppSlug: null, recordId: null };
|
|
|
|
async function findUserByEmailWhoIsAHostOfEventType({
|
|
email,
|
|
eventTypeId,
|
|
}: {
|
|
email: string;
|
|
eventTypeId: number;
|
|
}) {
|
|
return prisma.user.findFirst({
|
|
where: {
|
|
email,
|
|
hosts: { some: { eventTypeId } },
|
|
},
|
|
});
|
|
}
|
|
|
|
function getRoutingFormResponseIdFromQuery(query: ParsedUrlQuery) {
|
|
const routingFormResponseIdAsNumber = Number(query[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]);
|
|
const routingFormResponseId = isNaN(routingFormResponseIdAsNumber) ? null : routingFormResponseIdAsNumber;
|
|
return routingFormResponseId;
|
|
}
|
|
|
|
async function getAttributeRoutingConfig(
|
|
data:
|
|
| {
|
|
routingFormResponseId: number;
|
|
eventTypeId: number;
|
|
}
|
|
| {
|
|
route: Pick<LocalRoute, "attributeRoutingConfig">;
|
|
}
|
|
) {
|
|
if ("route" in data) {
|
|
return data.route.attributeRoutingConfig ?? null;
|
|
}
|
|
const { routingFormResponseId } = data;
|
|
|
|
const routingFormResponseQuery = await prisma.app_RoutingForms_FormResponse.findUnique({
|
|
where: {
|
|
id: routingFormResponseId,
|
|
},
|
|
include: {
|
|
form: {
|
|
select: {
|
|
routes: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!routingFormResponseQuery || !routingFormResponseQuery?.form.routes) return null;
|
|
const parsedRoutes = routesSchema.safeParse(routingFormResponseQuery?.form.routes);
|
|
|
|
if (!parsedRoutes.success || !parsedRoutes.data) return null;
|
|
|
|
// Find the route with the attributeRoutingConfig
|
|
const route = parsedRoutes.data.find((route) => {
|
|
if ("action" in route) {
|
|
return route.id === routingFormResponseQuery.chosenRouteId;
|
|
}
|
|
});
|
|
|
|
if (!route || !("attributeRoutingConfig" in route)) return null;
|
|
// Get attributeRoutingConfig for the form
|
|
const attributeRoutingConfig = route.attributeRoutingConfig;
|
|
|
|
if (!attributeRoutingConfig) return null;
|
|
return attributeRoutingConfig;
|
|
}
|
|
|
|
function getEnabledRoutingFormAppSlugFromQuery(query: ParsedUrlQuery) {
|
|
// Determine if a routing form enabled app is in the query. Then pass it to the proper handler
|
|
// Routing form apps will have the format cal.appSlug
|
|
let enabledRoutingFormApp;
|
|
|
|
for (const key of Object.keys(query)) {
|
|
const keySplit = key.split(".");
|
|
|
|
const appSlug = keySplit[1];
|
|
|
|
if (enabledAppSlugs.includes(appSlug)) {
|
|
enabledRoutingFormApp = appSlug;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return enabledRoutingFormApp;
|
|
}
|
|
|
|
/**
|
|
* Uses the owner of the contact directly from CRM
|
|
*/
|
|
async function getOwnerEmailFromCrm(
|
|
eventData: EventData,
|
|
email: string
|
|
): Promise<{
|
|
email: string | null;
|
|
recordType: string | null;
|
|
crmAppSlug: string | null;
|
|
recordId: string | null;
|
|
}> {
|
|
const crmContactOwner = await getCRMContactOwnerForRRLeadSkip(email, eventData.metadata);
|
|
|
|
if (!crmContactOwner?.email) return returnNullValue;
|
|
log.info(
|
|
"[getOwnerEmailFromCrm] crmContactOwner",
|
|
safeStringify({
|
|
contactOwnerEmail: crmContactOwner.email,
|
|
recordType: crmContactOwner.recordType,
|
|
email,
|
|
eventTypeId: eventData.id,
|
|
})
|
|
);
|
|
// Determine if the contactOwner is a part of the event type
|
|
const contactOwnerQuery = await findUserByEmailWhoIsAHostOfEventType({
|
|
email: crmContactOwner.email,
|
|
eventTypeId: eventData.id,
|
|
});
|
|
if (!contactOwnerQuery) {
|
|
log.info(
|
|
"[getOwnerEmailFromCrm] contactOwner not found as host, not considering it",
|
|
safeStringify({ contactOwnerEmail: crmContactOwner.email, email, eventTypeId: eventData.id })
|
|
);
|
|
return returnNullValue;
|
|
}
|
|
return crmContactOwner;
|
|
}
|
|
|
|
/**
|
|
* Handles custom lookup field logic
|
|
*/
|
|
async function getTeamMemberEmailUsingRoutingFormHandler({
|
|
bookerEmail,
|
|
eventTypeId,
|
|
attributeRoutingConfig,
|
|
crmAppSlug,
|
|
}: {
|
|
bookerEmail: string;
|
|
eventTypeId: number;
|
|
attributeRoutingConfig: AttributeRoutingConfig | null;
|
|
crmAppSlug: string;
|
|
}) {
|
|
const nullReturnValue = { email: null, skipContactOwner: false, recordType: "", recordId: "" };
|
|
|
|
if (!attributeRoutingConfig) return nullReturnValue;
|
|
|
|
// If the skipContactOwner is enabled then don't return an team member email
|
|
if (attributeRoutingConfig.skipContactOwner) return { ...nullReturnValue, skipContactOwner: true };
|
|
|
|
const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler"))
|
|
.default;
|
|
const appHandler = appBookingFormHandler[crmAppSlug];
|
|
if (!appHandler) return nullReturnValue;
|
|
|
|
const {
|
|
email: userEmail,
|
|
recordType,
|
|
recordId,
|
|
} = await appHandler(bookerEmail, attributeRoutingConfig, eventTypeId);
|
|
|
|
if (!userEmail) return nullReturnValue;
|
|
|
|
// Determine if the user is a part of the event type
|
|
const userQuery = await findUserByEmailWhoIsAHostOfEventType({ email: userEmail, eventTypeId });
|
|
|
|
if (!userQuery) return nullReturnValue;
|
|
|
|
return { ...nullReturnValue, email: userEmail, recordType, recordId };
|
|
}
|
|
|
|
async function getTeamMemberEmailForResponseOrContact({
|
|
bookerEmail,
|
|
eventData,
|
|
routingFormResponseId,
|
|
chosenRoute,
|
|
crmAppSlug,
|
|
}: {
|
|
bookerEmail: string;
|
|
eventData: EventData;
|
|
routingFormResponseId?: number | null;
|
|
/**
|
|
* If provided, we won't go look for the route from DB.
|
|
*/
|
|
chosenRoute?: LocalRoute;
|
|
crmAppSlug?: string;
|
|
}): Promise<{
|
|
email: string | null;
|
|
recordType: string | null;
|
|
crmAppSlug: string | null;
|
|
recordId: string | null;
|
|
}> {
|
|
const eventTypeId = eventData.id;
|
|
if (eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return returnNullValue;
|
|
|
|
const attributeRoutingConfigGetterData = routingFormResponseId
|
|
? { routingFormResponseId, eventTypeId }
|
|
: chosenRoute
|
|
? { route: chosenRoute }
|
|
: null;
|
|
|
|
// If we have found crmAppSlug, it means that the CRM App in the routing-form will handle the logic
|
|
if (attributeRoutingConfigGetterData && crmAppSlug) {
|
|
log.debug(
|
|
"Using CRM App handler in routing-forms",
|
|
safeStringify({ attributeRoutingConfigGetterData, crmAppSlug })
|
|
);
|
|
const attributeRoutingConfig = await getAttributeRoutingConfig(attributeRoutingConfigGetterData);
|
|
const { email, skipContactOwner, recordType, recordId } = await getTeamMemberEmailUsingRoutingFormHandler(
|
|
{
|
|
bookerEmail,
|
|
eventTypeId,
|
|
attributeRoutingConfig,
|
|
crmAppSlug,
|
|
}
|
|
);
|
|
|
|
if (skipContactOwner) return returnNullValue;
|
|
if (email) return { email, recordType, crmAppSlug, recordId };
|
|
} else {
|
|
log.debug("Getting the contact owner email from CRM");
|
|
return await getOwnerEmailFromCrm(eventData, bookerEmail);
|
|
}
|
|
|
|
return returnNullValue;
|
|
}
|
|
|
|
export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
|
query,
|
|
eventData,
|
|
chosenRoute,
|
|
}: {
|
|
query: ParsedUrlQuery;
|
|
eventData: EventData;
|
|
chosenRoute?: LocalRoute;
|
|
}): Promise<{
|
|
email: string | null;
|
|
recordType: string | null;
|
|
crmAppSlug: string | null;
|
|
recordId: string | null;
|
|
}> {
|
|
// Without email no lookup is possible
|
|
if (!query.email || typeof query.email !== "string") {
|
|
return returnNullValue;
|
|
}
|
|
|
|
log.debug("getTeamMemberEmailForResponseOrContactUsingUrlQuery", safeStringify({ query }));
|
|
|
|
const crmAppSlug = getEnabledRoutingFormAppSlugFromQuery(query);
|
|
|
|
const routingFormResponseId = getRoutingFormResponseIdFromQuery(query);
|
|
|
|
return await getTeamMemberEmailForResponseOrContact({
|
|
bookerEmail: query.email,
|
|
eventData,
|
|
routingFormResponseId,
|
|
chosenRoute,
|
|
crmAppSlug,
|
|
});
|
|
}
|