feat: Add routing-forms record response endpoint with available slots (#22239)
* feat: Add routing-forms record response endpoint with available slots * fix: resolve TypeScript error in handleResponse.test.ts - Fix type mismatch where mockResponse was passed as identifierKeyedResponse - identifierKeyedResponse expects Record<string, string | string[]> structure - Updated test to pass correct data structure for type compatibility Co-Authored-By: hariom@cal.com <hariom@cal.com> * fix: correct POST endpoint parameter handling and request body parsing in routing forms responses controller - Change @Query() to @Body() decorator for POST request data in controller - Update service method to accept parsed body data directly - Remove incorrect URLSearchParams parsing of request.body object - Fix getRoutingUrl method to use form response data parameter This resolves API v2 test failures by following proper NestJS patterns for POST request handling. Co-Authored-By: hariom@cal.com <hariom@cal.com> * Pass teamMemberEmail as well * Devin fixes reverted * Keep all routing related props together in both endpoints * Remove newly added slots props from Slots documentation as they are used through internal fn call only * fix test * Pass skipContactOwner * Pass crmAppSlug and crmOwnerRecordGType and add more tests * handle external redirect case and form not found case * hide props * chore: bump platform libs --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: hariom@cal.com <hariom@cal.com> Co-authored-by: cal.com <morgan@cal.com> Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
23 files changed
+1559
-34
No files matched your search
@@ -1,32 +1,30 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
|
||||
import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { ZResponseInputSchema } from "@calcom/trpc/server/routers/viewer/routing-forms/response.schema";
|
||||
|
||||
import type { LocalRoute } from "../../types/types";
|
||||
import { enabledAppSlugs } from "../enabledApps";
|
||||
|
||||
export default async function routerGetCrmContactOwnerEmail({
|
||||
attributeRoutingConfig,
|
||||
response,
|
||||
identifierKeyedResponse,
|
||||
action,
|
||||
}: {
|
||||
attributeRoutingConfig: LocalRoute["attributeRoutingConfig"];
|
||||
response: z.infer<typeof ZResponseInputSchema>["response"];
|
||||
identifierKeyedResponse: Record<string, string | string[]> | null;
|
||||
action: LocalRoute["action"];
|
||||
}) {
|
||||
// Check if route is skipping CRM contact check
|
||||
if (attributeRoutingConfig?.skipContactOwner) return null;
|
||||
|
||||
// Check if email is present
|
||||
let prospectEmail: string | null = null;
|
||||
for (const field of Object.keys(response)) {
|
||||
const fieldResponse = response[field];
|
||||
if (fieldResponse.identifier === "email") {
|
||||
prospectEmail = fieldResponse.value as string;
|
||||
break;
|
||||
if (identifierKeyedResponse) {
|
||||
for (const identifier of Object.keys(identifierKeyedResponse)) {
|
||||
const fieldResponse = identifierKeyedResponse[identifier];
|
||||
if (identifier === "email") {
|
||||
prospectEmail = fieldResponse instanceof Array ? fieldResponse[0] : fieldResponse;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prospectEmail) return null;
|
||||
@@ -65,7 +63,7 @@ export default async function routerGetCrmContactOwnerEmail({
|
||||
}
|
||||
}
|
||||
|
||||
if (!contactOwner) {
|
||||
if (!contactOwner || (!contactOwner.email && !contactOwner.recordType)) {
|
||||
const ownerQuery = await getCRMContactOwnerForRRLeadSkip(prospectEmail, eventTypeMetadata);
|
||||
if (ownerQuery?.email) contactOwner = ownerQuery;
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ describe("handleResponse", () => {
|
||||
handleResponse({
|
||||
response: { email: { value: "test@test.com", label: "Email" } }, // Name is missing
|
||||
form: mockForm,
|
||||
identifierKeyedResponse: null,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: null,
|
||||
isPreview: false,
|
||||
@@ -129,6 +130,7 @@ describe("handleResponse", () => {
|
||||
email: { value: "invalid-email", label: "Email" },
|
||||
},
|
||||
form: mockForm,
|
||||
identifierKeyedResponse: null,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: null,
|
||||
isPreview: false,
|
||||
@@ -153,6 +155,7 @@ describe("handleResponse", () => {
|
||||
const result = await handleResponse({
|
||||
response: mockResponse,
|
||||
form: mockForm,
|
||||
identifierKeyedResponse: null,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: null,
|
||||
isPreview: false,
|
||||
@@ -187,6 +190,7 @@ describe("handleResponse", () => {
|
||||
const result = await handleResponse({
|
||||
response: mockResponse,
|
||||
form: mockForm,
|
||||
identifierKeyedResponse: null,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: null,
|
||||
isPreview: false,
|
||||
@@ -209,6 +213,7 @@ describe("handleResponse", () => {
|
||||
const result = await handleResponse({
|
||||
response: mockResponse,
|
||||
form: mockForm,
|
||||
identifierKeyedResponse: null,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: null,
|
||||
isPreview: true,
|
||||
@@ -225,6 +230,7 @@ describe("handleResponse", () => {
|
||||
const result = await handleResponse({
|
||||
response: mockResponse,
|
||||
form: mockForm,
|
||||
identifierKeyedResponse: null,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: null,
|
||||
isPreview: true,
|
||||
@@ -275,6 +281,11 @@ describe("handleResponse", () => {
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: "route1",
|
||||
isPreview: false,
|
||||
identifierKeyedResponse: {
|
||||
name: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
},
|
||||
fetchCrm: true,
|
||||
});
|
||||
|
||||
expect(routerGetCrmContactOwnerEmail).toHaveBeenCalled();
|
||||
@@ -301,6 +312,7 @@ describe("handleResponse", () => {
|
||||
handleResponse({
|
||||
response: mockResponse,
|
||||
form: formWithRoute,
|
||||
identifierKeyedResponse: null,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: "route1",
|
||||
isPreview: false,
|
||||
|
||||
@@ -19,19 +19,23 @@ const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/handleRes
|
||||
|
||||
const _handleResponse = async ({
|
||||
response,
|
||||
identifierKeyedResponse,
|
||||
form,
|
||||
// Unused but probably should be used
|
||||
// formFillerId,
|
||||
chosenRouteId,
|
||||
isPreview,
|
||||
queueFormResponse,
|
||||
fetchCrm,
|
||||
}: {
|
||||
response: z.infer<typeof ZResponseInputSchema>["response"];
|
||||
identifierKeyedResponse: Record<string, string | string[]> | null;
|
||||
form: TargetRoutingFormForResponse;
|
||||
formFillerId: string;
|
||||
chosenRouteId: string | null;
|
||||
isPreview: boolean;
|
||||
queueFormResponse?: boolean;
|
||||
fetchCrm?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
if (!form.fields) {
|
||||
@@ -103,11 +107,14 @@ const _handleResponse = async ({
|
||||
const getRoutedMembers = async () =>
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
const contactOwnerQuery = await routerGetCrmContactOwnerEmail({
|
||||
attributeRoutingConfig: chosenRoute.attributeRoutingConfig,
|
||||
response,
|
||||
action: chosenRoute.action,
|
||||
});
|
||||
const contactOwnerQuery =
|
||||
identifierKeyedResponse && fetchCrm
|
||||
? await routerGetCrmContactOwnerEmail({
|
||||
attributeRoutingConfig: chosenRoute.attributeRoutingConfig,
|
||||
identifierKeyedResponse,
|
||||
action: chosenRoute.action,
|
||||
})
|
||||
: null;
|
||||
crmContactOwnerEmail = contactOwnerQuery?.email ?? null;
|
||||
crmContactOwnerRecordType = contactOwnerQuery?.recordType ?? null;
|
||||
crmAppSlug = contactOwnerQuery?.crmAppSlug ?? null;
|
||||
|
||||
@@ -49,7 +49,8 @@ export function hasEmbedPath(pathWithQuery: string) {
|
||||
return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/");
|
||||
}
|
||||
|
||||
const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" | "req">) => {
|
||||
// We have fetchCrm as configurable temporarily to allow us to test the CRM logic in the APIV2. Soon after we would hardcode it to true
|
||||
const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" | "req">, fetchCrm = false) => {
|
||||
const queryParsed = querySchema.safeParse(context.query);
|
||||
const isEmbed = hasEmbedPath(context.req.url || "");
|
||||
const pageProps = {
|
||||
@@ -148,9 +149,11 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
form: serializableForm,
|
||||
formFillerId: uuidv4(),
|
||||
response: response,
|
||||
identifierKeyedResponse: fieldsResponses,
|
||||
chosenRouteId: matchingRoute.id,
|
||||
isPreview: isBookingDryRun,
|
||||
queueFormResponse: shouldQueueFormResponse,
|
||||
fetchCrm,
|
||||
});
|
||||
teamMembersMatchingAttributeLogic = result.teamMembersMatchingAttributeLogic;
|
||||
formResponseId = result.formResponse?.id;
|
||||
|
||||
@@ -145,6 +145,61 @@ class Attendee {
|
||||
language?: BookingLanguageType;
|
||||
}
|
||||
|
||||
class Routing {
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
description: "The ID of the routing form response that determined this booking assignment.",
|
||||
example: 123,
|
||||
})
|
||||
@IsInt()
|
||||
responseId!: number;
|
||||
|
||||
@ApiProperty({
|
||||
type: [Number],
|
||||
description: "Array of team member IDs that were routed to handle this booking.",
|
||||
example: [101, 102],
|
||||
})
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
teamMemberIds!: number[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "The email of the team member assigned to handle this booking.",
|
||||
example: "john.doe@example.com",
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
teamMemberEmail?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
description: "Whether to skip contact owner assignment from CRM integration.",
|
||||
example: true,
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
skipContactOwner?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "The CRM application slug for integration.",
|
||||
example: "salesforce",
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
crmAppSlug?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "The CRM owner record type for contact assignment.",
|
||||
example: "Account",
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
crmOwnerRecordType?: string;
|
||||
}
|
||||
|
||||
@ApiExtraModels(
|
||||
BookingInputAddressLocation_2024_08_13,
|
||||
BookingInputAttendeeAddressLocation_2024_08_13,
|
||||
@@ -302,6 +357,20 @@ export class CreateBookingInput_2024_08_13 {
|
||||
If not provided then event type default length will be used for the booking.`,
|
||||
})
|
||||
lengthInMinutes?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Routing,
|
||||
description:
|
||||
"Routing information from routing forms that determined the booking assignment. Both responseId and teamMemberIds are required if provided.",
|
||||
example: {
|
||||
responseId: 123,
|
||||
teamMemberIds: [101, 102],
|
||||
},
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => Routing)
|
||||
routing?: Routing;
|
||||
}
|
||||
|
||||
export class CreateInstantBookingInput_2024_08_13 extends CreateBookingInput_2024_08_13 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional, ApiHideProperty } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import {
|
||||
IsDateString,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IsArray,
|
||||
ArrayMinSize,
|
||||
IsEnum,
|
||||
IsBoolean,
|
||||
} from "class-validator";
|
||||
|
||||
import { SlotFormat } from "@calcom/platform-enums";
|
||||
@@ -88,6 +89,26 @@ export class GetAvailableSlotsInput_2024_09_04 {
|
||||
example: "abc123def456",
|
||||
})
|
||||
bookingUidToReschedule?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiHideProperty()
|
||||
teamMemberEmail?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@ApiHideProperty()
|
||||
routingFormResponseId?: number;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@ApiHideProperty()
|
||||
routedTeamMemberIds?: number[];
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiHideProperty()
|
||||
skipContactOwner?: boolean;
|
||||
}
|
||||
|
||||
export const ById_2024_09_04_type = "byEventTypeId";
|
||||
|
||||
@@ -44,7 +44,14 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
|
||||
form,
|
||||
});
|
||||
|
||||
return handleResponse({ response, form: serializableForm, formFillerId, chosenRouteId, isPreview });
|
||||
return handleResponse({
|
||||
response,
|
||||
identifierKeyedResponse: null,
|
||||
form: serializableForm,
|
||||
formFillerId,
|
||||
chosenRouteId,
|
||||
isPreview,
|
||||
});
|
||||
};
|
||||
|
||||
export default responseHandler;
|
||||
Reference in new issue
Block a user