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:
authored and GitHub committed 2025-07-04 12:33:13 +00:00
1 parent c90fded6b7
commit 056b821070
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;
+4 -1
View File
@@ -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;