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:
Hariom Balhara
2025-07-04 12:33:13 +00:00
committed by GitHub
co-authored by hariom@cal.com <hariom@cal.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> hariom@cal.com <hariom@cal.com> cal.com Morgan
parent c90fded6b7
commit 056b821070
23 changed files with 1559 additions and 34 deletions
@@ -37,7 +37,13 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
let user: User;
const userEmail = `OrganizationsRoutingFormsResponsesController-key-bookings-2024-08-13-user-${randomString()}@api.com`;
let profileRepositoryFixture: ProfileRepositoryFixture;
let routingEventType: {
id: number;
slug: string | null;
teamId: number | null;
userId: number | null;
title: string;
}
let membershipsRepositoryFixture: MembershipRepositoryFixture;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
@@ -72,6 +78,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
role: "OWNER",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
accepted: true,
});
await profileRepositoryFixture.create({
@@ -93,14 +100,74 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = `${keyString}`;
// Create an event type for routing form to route to
routingEventType = await prismaWriteService.prisma.eventType.create({
data: {
title: "Test Event Type",
slug: "test-event-type",
length: 30,
userId: user.id,
teamId: team.id,
},
});
routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test Routing Form",
description: "Test Description",
disabled: false,
routes: JSON.stringify([]),
fields: JSON.stringify([]),
settings: JSON.stringify({}),
routes: [
{
id: "route-1",
queryValue: {
id: "route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"]
}
}
}
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: routingEventType.id,
value: `team/${team.slug}/${routingEventType.slug}`
},
isFallback: false
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" }
}
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1"
},
{
id: "question2",
type: "text",
label: "Question 2",
required: false,
identifier: "question2"
}
],
settings: {
emailOwnerOnSubmission: false
},
teamId: team.id,
userId: user.id,
},
@@ -175,6 +242,353 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
});
});
describe(`POST /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
it("should return 403 when organization does not exist", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/99999/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
})
.expect(403);
});
it("should return 404 when routing form does not exist", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses?start=2050-09-05&end=2050-09-06`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
})
.expect(404);
});
it("should return 401 when authentication token is missing", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.send({
question1: "answer1",
})
.expect(401);
});
it("should create response and return available slots when routing to event type", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1", // This matches the route condition
question2: "answer2",
})
.expect(201)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.routing?.responseId).toBeDefined();
expect(typeof data.routing?.responseId).toBe("number");
expect(data.eventTypeId).toEqual(routingEventType.id);
expect(data.slots).toBeDefined();
expect(typeof data.slots).toBe("object");
});
});
it("should return 400 when required form fields are missing", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question2: "answer2", // Missing required question1
})
.expect(400);
});
it("should create response and return custom message if the routing is to custom page", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "different-answer", // This won't match any route
question2: "answer2",
})
.expect(201)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.routingCustomMessage).toBeDefined();
expect(data.routingCustomMessage).toBe("Fallback Message");
});
});
it("should return 400 when required slot query parameters are missing", async () => {
// Missing start parameter
await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?end=2050-09-06`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
question2: "answer2",
})
.expect(400);
// Missing end parameter
await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
question2: "answer2",
})
.expect(400);
});
it("should return 400 when date parameters have invalid format", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=invalid-date&end=2050-09-06`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
})
.expect(400);
});
it("should return 400 when end date is before start date", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-10&end=2050-09-05`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
})
.expect(400);
});
it("should return 403 when user lacks permission to access organization", async () => {
// Create a new user without organization access
const unauthorizedUser = await userRepositoryFixture.create({
email: `unauthorized-user-${randomString()}@api.com`,
});
const { keyString: unauthorizedApiKey } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.set({ Authorization: `Bearer cal_test_${unauthorizedApiKey}` })
.send({
question1: "answer1",
});
expect(response.status).toBe(403);
// Clean up
await prismaWriteService.prisma.user.delete({
where: { id: unauthorizedUser.id },
});
});
it("should handle queued response creation", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
question2: "answer2",
})
.expect(201)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.routing?.queuedResponseId).toBeDefined();
});
});
it("should return 500 when event type is not found", async () => {
// Create a routing form with an invalid eventTypeId
const routingFormWithInvalidEventType = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test Routing Form with Invalid Event Type",
description: "Test Description",
disabled: false,
routes: [
{
id: "route-1",
queryValue: {
id: "route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"]
}
}
}
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: 99999, // Invalid event type ID
value: `team/${team.slug}/non-existent-event-type`
},
isFallback: false
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" }
}
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1"
}
],
settings: {
emailOwnerOnSubmission: false
},
teamId: team.id,
userId: user.id,
},
});
// Try to create a response for the form with invalid event type
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
});
expect(response.status).toBe(500);
// Clean up the form
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: routingFormWithInvalidEventType.id }
});
});
it("should handle routing with team member assignments", async () => {
// This test verifies that routing forms can handle team member assignments
// and that the routing returns the correct team member information
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1", // This matches the route condition
question2: "answer2",
})
.expect(201)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.routing?.responseId).toBeDefined();
expect(typeof data.routing?.responseId).toBe("number");
expect(data.eventTypeId).toEqual(routingEventType.id);
expect(data.slots).toBeDefined();
// Team member assignments would be part of the routing data
// This test validates the basic routing functionality works
});
});
it("should return external redirect URL when routing to external URL", async () => {
// Create a routing form with external redirect action
const externalRoutingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test External Routing Form",
description: "Test Description for External Redirect",
disabled: false,
routes: [
{
id: "external-route-1",
queryValue: {
id: "external-route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["external"],
valueSrc: ["value"],
valueType: ["text"]
}
}
}
},
action: {
type: "externalRedirectUrl",
value: "https://example.com/external-booking"
},
isFallback: false
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" }
}
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1"
}
],
settings: {
emailOwnerOnSubmission: false
},
teamId: team.id,
userId: user.id,
},
});
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${externalRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "external", // This matches the route condition for external redirect
})
.expect(201);
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.routingExternalRedirectUrl).toBeDefined();
expect(data.routingExternalRedirectUrl).toContain("https://example.com/external-booking");
expect(data.routingExternalRedirectUrl).toContain("cal.action=externalRedirectUrl");
// Verify that it doesn't contain event type routing data
expect(data.eventTypeId).toBeUndefined();
expect(data.slots).toBeUndefined();
expect(data.routing).toBeUndefined();
expect(data.routingCustomMessage).toBeUndefined();
// Clean up the external routing form
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: externalRoutingForm.id }
});
});
});
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
it("should not update routing form response for non existing org", async () => {
return request(app.getHttpServer())
@@ -9,13 +9,28 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { GetRoutingFormResponsesOutput } from "@/modules/organizations/routing-forms/outputs/get-routing-form-responses.output";
import { OrganizationsRoutingFormsResponsesService } from "@/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service";
import { Body, Controller, Get, Param, Patch, Query, UseGuards, ParseIntPipe } from "@nestjs/common";
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
ParseIntPipe,
Req,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
import { Request } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetAvailableSlotsInput_2024_09_04 } from "@calcom/platform-types";
import { CreateRoutingFormResponseInput } from "../inputs/create-routing-form-response.input";
import { GetRoutingFormResponsesParams } from "../inputs/get-routing-form-responses-params.input";
import { UpdateRoutingFormResponseInput } from "../inputs/update-routing-form-response.input";
import { CreateRoutingFormResponseOutput } from "../outputs/create-routing-form-response.output";
import { UpdateRoutingFormResponseOutput } from "../outputs/update-routing-form-response.output";
@Controller({
@@ -56,6 +71,29 @@ export class OrganizationsRoutingFormsResponsesController {
};
}
@Post("/")
@ApiOperation({ summary: "Create routing form response and get available slots" })
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
async createRoutingFormResponse(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("routingFormId") routingFormId: string,
@Query() query: CreateRoutingFormResponseInput,
@Req() request: Request
): Promise<CreateRoutingFormResponseOutput> {
const result = await this.organizationsRoutingFormsResponsesService.createRoutingFormResponseWithSlots(
orgId,
routingFormId,
query,
request
);
return {
status: SUCCESS_STATUS,
data: result,
};
}
@Patch("/:responseId")
@ApiOperation({ summary: "Update routing form response" })
@Roles("ORG_ADMIN")
@@ -0,0 +1,23 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsBoolean, IsOptional } from "class-validator";
import { GetAvailableSlotsInput_2024_09_04 } from "@calcom/platform-types";
export class CreateRoutingFormResponseInput extends GetAvailableSlotsInput_2024_09_04 {
@Transform(({ value }: { value: string | boolean }) => {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
return value.toLowerCase() === 'true';
}
return undefined;
})
@IsBoolean()
@IsOptional()
@ApiPropertyOptional({
type: Boolean,
description: "Whether to queue the form response.",
example: true,
})
queueResponse?: boolean;
}
@@ -4,7 +4,9 @@ import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { Module } from "@nestjs/common";
import { OrganizationsRoutingFormsResponsesController } from "./controllers/organizations-routing-forms-responses.controller";
@@ -14,7 +16,7 @@ import { OrganizationsRoutingFormsResponsesService } from "./services/organizati
import { OrganizationsRoutingFormsService } from "./services/organizations-routing-forms.service";
@Module({
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule],
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule, SlotsModule_2024_09_04],
providers: [
MembershipsRepository,
OrganizationsRepository,
@@ -22,6 +24,7 @@ import { OrganizationsRoutingFormsService } from "./services/organizations-routi
OrganizationsRoutingFormsService,
OrganizationsRoutingFormsResponsesService,
OrganizationsTeamsRoutingFormsResponsesOutputService,
TeamsEventTypesRepository,
],
controllers: [OrganizationsRoutingFormsController, OrganizationsRoutingFormsResponsesController],
})
@@ -0,0 +1,136 @@
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
import { ApiResponseWithoutData, SlotsOutput_2024_09_04, RangeSlotsOutput_2024_09_04 } from "@calcom/platform-types";
class Routing {
@ApiProperty({
type: String,
description: "The ID of the queued form response. Only present if the form response was queued.",
example: "123",
})
@IsString()
@IsOptional()
@ApiPropertyOptional()
queuedResponseId?: string | null;
@ApiProperty({
type: Number,
description: "The ID of the routing form response.",
example: 123,
})
@IsInt()
@IsOptional()
@ApiPropertyOptional()
responseId?: number | null;
@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;
}
export class CreateRoutingFormResponseOutputData {
@ApiPropertyOptional({
type: Number,
description: "The ID of the event type that was routed to.",
example: 123,
})
@IsNumber()
@IsOptional()
eventTypeId?: number;
@ValidateNested()
@ApiProperty({ type: Routing })
@Type(() => Routing)
@ApiPropertyOptional({
type: Routing,
description: "The routing information.",
example: {
eventTypeId: 123,
routing: {
teamMemberIds: [101, 102],
teamMemberEmail: "john.doe@example.com",
skipContactOwner: true,
},
},
})
@ValidateNested()
@Type(() => Routing)
routing?: Routing;
@IsString()
@IsOptional()
@ApiPropertyOptional({
type: String,
description: "A custom message to be displayed to the user in case of routing to a custom page.",
example: "This is a custom message.",
})
routingCustomMessage?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
type: String,
description: "The external redirect URL to be used in case of routing to a non cal.com event type URL.",
example: "https://example.com/",
})
routingExternalRedirectUrl?: string;
@ValidateNested()
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(SlotsOutput_2024_09_04) },
{ $ref: getSchemaPath(RangeSlotsOutput_2024_09_04) },
],
})
@Type(() => Object)
slots?: SlotsOutput_2024_09_04 | RangeSlotsOutput_2024_09_04;
}
export class CreateRoutingFormResponseOutput extends ApiResponseWithoutData {
@ValidateNested()
@ApiProperty({ type: CreateRoutingFormResponseOutputData })
@Type(() => CreateRoutingFormResponseOutputData)
data!: CreateRoutingFormResponseOutputData;
}
@@ -1,12 +1,23 @@
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
import { Injectable } from "@nestjs/common";
import { SlotsService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots.service";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { BadRequestException, Injectable, NotFoundException, InternalServerErrorException } from "@nestjs/common";
import { Request } from "express";
import { getRoutedUrl } from "@calcom/platform-libraries";
import { ById_2024_09_04_type } from "@calcom/platform-types";
import type { CreateRoutingFormResponseInput } from "../inputs/create-routing-form-response.input";
import type { CreateRoutingFormResponseOutputData } from "../outputs/create-routing-form-response.output";
@Injectable()
export class OrganizationsRoutingFormsResponsesService {
constructor(
private readonly organizationsRoutingFormsRepository: OrganizationsRoutingFormsRepository,
private readonly outputService: OrganizationsTeamsRoutingFormsResponsesOutputService
private readonly outputService: OrganizationsTeamsRoutingFormsResponsesOutputService,
private readonly slotsService: SlotsService_2024_09_04,
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
) {}
async getOrganizationRoutingFormResponses(
@@ -52,4 +63,194 @@ export class OrganizationsRoutingFormsResponsesService {
return this.outputService.getRoutingFormResponses([updatedResponse])[0];
}
async createRoutingFormResponseWithSlots(
orgId: number,
routingFormId: string,
query: CreateRoutingFormResponseInput,
request: Request
): Promise<CreateRoutingFormResponseOutputData> {
const { queueResponse, ...slotsQuery } = query;
this.validateDateRange(slotsQuery.start, slotsQuery.end);
const { redirectUrl, customMessage } = await this.getRoutingUrl(request, routingFormId, queueResponse ?? false);
// If there is no redirect URL, then we have to show the message as that would be custom page message to be shown as per the route chosen
if (!redirectUrl) {
return {
routingCustomMessage: customMessage,
};
}
if (!this.isEventTypeRedirectUrl(redirectUrl)) {
return {
routingExternalRedirectUrl: redirectUrl.toString(),
}
}
// Extract event type information from the routed URL
const { eventTypeId, crmParams } = await this.extractEventTypeAndCrmParams(redirectUrl);
const paramsForGetAvailableSlots = {
type: ById_2024_09_04_type,
eventTypeId,
...slotsQuery,
...crmParams,
} as const;
// Get available slots using the slots service with CRM parameters
const slots = await this.slotsService.getAvailableSlots(paramsForGetAvailableSlots);
const teamMemberIds = crmParams.routedTeamMemberIds ?? [];
const teamMemberEmail = crmParams.teamMemberEmail ?? undefined;
const skipContactOwner = crmParams.skipContactOwner ?? undefined;
const queuedResponseId = crmParams.queuedFormResponseId ?? null;
const responseId = crmParams.routingFormResponseId ?? null;
const crmAppSlug = crmParams.crmAppSlug ?? undefined;
const crmOwnerRecordType = crmParams.crmOwnerRecordType ?? undefined;
if (responseId) {
return {
routing: {
responseId,
teamMemberEmail,
teamMemberIds,
skipContactOwner,
crmAppSlug,
crmOwnerRecordType,
},
eventTypeId,
slots,
};
}
if (!queuedResponseId) {
throw new InternalServerErrorException("No routing form response ID or queued form response ID could be found.");
}
return {
routing: {
queuedResponseId,
teamMemberEmail,
teamMemberIds,
skipContactOwner,
crmAppSlug,
crmOwnerRecordType,
},
eventTypeId,
slots,
};
}
private validateDateRange(start: string, end: string) {
const startDate = new Date(start);
const endDate = new Date(end);
if (endDate < startDate) {
throw new BadRequestException("End date cannot be before start date.");
}
}
private async getRoutingUrl(request: Request, formId: string, queueResponse: boolean) {
const params = Object.fromEntries(new URLSearchParams(request.body));
const routedUrlData = await getRoutedUrl({
req: request,
query: { ...params, form: formId, ...(queueResponse && { "cal.queueFormResponse": "true" }) },
}, true);
if (routedUrlData.notFound) {
throw new NotFoundException("Routing form not found.");
}
if (routedUrlData?.props?.errorMessage) {
throw new BadRequestException(routedUrlData.props.errorMessage);
}
const destination = routedUrlData?.redirect?.destination;
if (!destination) {
if (routedUrlData?.props?.message) {
return {
redirectUrl: null,
customMessage: routedUrlData.props.message,
};
}
// This should never happen because there is always a fallback route
throw new InternalServerErrorException("No route found.");
}
return {
redirectUrl: new URL(destination),
customMessage: null,
};
}
private async extractEventTypeAndCrmParams(routingUrl: URL) {
// Extract team and event type information
const { teamId, eventTypeSlug } = this.extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl);
const eventType = await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlug(teamId, eventTypeSlug);
if (!eventType?.id) {
// This could only happen if the event-type earlier selected as route action was deleted
throw new InternalServerErrorException("Chosen event type not found.");
}
// Extract CRM parameters from URL
const urlParams = routingUrl.searchParams;
const crmParams = {
teamMemberEmail: urlParams.get("cal.crmContactOwnerEmail") || undefined,
routedTeamMemberIds: urlParams.get("cal.routedTeamMemberIds")
? urlParams
.get("cal.routedTeamMemberIds")!
.split(",")
.map((id) => parseInt(id))
: undefined,
routingFormResponseId: urlParams.get("cal.routingFormResponseId")
? parseInt(urlParams.get("cal.routingFormResponseId")!)
: undefined,
queuedFormResponseId: urlParams.get("cal.queuedFormResponseId")
? (urlParams.get("cal.queuedFormResponseId") as string)
: undefined,
skipContactOwner: urlParams.get("cal.skipContactOwner") === "true" ? true : false,
crmAppSlug: urlParams.get("cal.crmAppSlug") || undefined,
crmOwnerRecordType: urlParams.get("cal.crmContactOwnerRecordType") || undefined,
};
return {
eventTypeId: eventType.id,
crmParams,
};
}
private isEventTypeRedirectUrl(routingUrl: URL) {
const routingSearchParams = routingUrl.searchParams;
return routingSearchParams.get("cal.action") === "eventTypeRedirectUrl";
}
private extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl: URL) {
const eventTypeSlug = this.extractEventTypeFromRoutedUrl(routingUrl);
const teamId = this.extractTeamIdFromRoutedUrl(routingUrl);
if (!teamId) {
throw new NotFoundException("Team ID not found in the routed URL.");
}
if (!eventTypeSlug) {
throw new NotFoundException("Event type slug not found in the routed URL.");
}
return { teamId, eventTypeSlug };
}
private extractTeamIdFromRoutedUrl(routingUrl: URL) {
const routingSearchParams = routingUrl.searchParams;
return Number(routingSearchParams.get("cal.teamId"));
}
private extractEventTypeFromRoutedUrl(routingUrl: URL) {
const pathNameParams = routingUrl.pathname.split("/");
return pathNameParams[pathNameParams.length - 1];
}
}
@@ -6,7 +6,9 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/inde
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { Module } from "@nestjs/common";
import { OrganizationsTeamsRoutingFormsResponsesController } from "./controllers/organizations-teams-routing-forms-responses.controller";
@@ -18,7 +20,7 @@ import { OrganizationsTeamsRoutingFormsResponsesService } from "./services/organ
import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-teams-routing-forms.service";
@Module({
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule],
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule, SlotsModule_2024_09_04, TeamsEventTypesModule],
providers: [
OrganizationsTeamsRoutingFormsService,
OrganizationsTeamsRoutingFormsResponsesService,
@@ -25,9 +25,9 @@ export class RouterController {
@Param("formId") formId: string,
@Body() body?: Record<string, string>
): Promise<void | (ApiResponse<unknown> & { redirect: boolean })> {
const params = Object.fromEntries(new URLSearchParams(body ?? {}));
const routedUrlData = await getRoutedUrl({ req: request, query: { ...params, form: formId } });
if (routedUrlData?.notFound) {
throw new NotFoundException("Route not found. Please check the provided form parameter.");
}
@@ -37,6 +37,13 @@ export class RouterController {
}
if (routedUrlData?.props) {
if (routedUrlData.props.errorMessage) {
return {
status: "error",
error: { code: "ROUTING_ERROR", message: routedUrlData.props.errorMessage },
redirect: false,
};
}
return { status: "success", data: { message: routedUrlData.props.message ?? "" }, redirect: false };
}
@@ -54,6 +61,7 @@ export class RouterController {
) {
return this.handleRedirectWithContactOwner(routingUrl, routingSearchParams);
}
console.log("handleRedirect Regular called", { destination });
return { status: "success", data: destination, redirect: true };
}
@@ -62,6 +70,7 @@ export class RouterController {
routingUrl: URL,
routingSearchParams: URLSearchParams
): Promise<ApiResponse<unknown> & { redirect: boolean }> {
console.log("handleRedirectWithContactOwner called", { routingUrl, routingSearchParams });
const pathNameParams = routingUrl.pathname.split("/");
const eventTypeSlug = pathNameParams[pathNameParams.length - 1];
const teamId = Number(routingSearchParams.get("cal.teamId"));
@@ -23,7 +23,6 @@ export class RoutingFormsService {
if (!eventTypeId) {
throw new NotFoundException("Event type not found.");
}
const slots = await this.slotsService.getAvailableSlots({
type: ById_2024_09_04_type,
eventTypeId,
@@ -10,7 +10,6 @@ import { DateTime } from "luxon";
import { dynamicEvent } from "@calcom/platform-libraries";
import {
ById_2024_09_04,
ByUsernameAndEventTypeSlug_2024_09_04,
ByTeamSlugAndEventTypeSlug_2024_09_04,
GetSlotsInput_2024_09_04,
@@ -47,6 +46,10 @@ export class SlotsInputService_2024_09_04 {
const timeZone = query.timeZone;
const orgSlug = "organizationSlug" in query ? query.organizationSlug : null;
const rescheduleUid = query.bookingUidToReschedule || null;
const routedTeamMemberIds = query.routedTeamMemberIds || null;
const skipContactOwner = query.skipContactOwner || false;
const teamMemberEmail = query.teamMemberEmail || null;
const routingFormResponseId = query.routingFormResponseId || null;
return {
isTeamEvent,
@@ -59,6 +62,10 @@ export class SlotsInputService_2024_09_04 {
timeZone,
orgSlug,
rescheduleUid,
routedTeamMemberIds,
skipContactOwner,
teamMemberEmail,
routingFormResponseId,
};
}
@@ -47,6 +47,7 @@ export class SlotsService_2024_09_04 {
const availableSlots: TimeSlots = await getAvailableSlots({
input: {
...queryTransformed,
routingFormResponseId: queryTransformed.routingFormResponseId ?? undefined,
},
ctx: {},
});