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:
co-authored by
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
hariom@cal.com <hariom@cal.com>
cal.com
Morgan
parent
c90fded6b7
commit
056b821070
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.236",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.239",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
@@ -186,6 +186,20 @@ export class InputBookingsService_2024_08_13 {
|
||||
guests,
|
||||
location,
|
||||
},
|
||||
...this.getRoutingFormData(inputBooking.routing),
|
||||
};
|
||||
}
|
||||
|
||||
private getRoutingFormData(routing: { teamMemberIds?: number[]; responseId?: number; teamMemberEmail?: string; skipContactOwner?: boolean; crmAppSlug?: string; crmOwnerRecordType?: string } | undefined) {
|
||||
if (!routing) return null;
|
||||
|
||||
return {
|
||||
routedTeamMemberIds: routing.teamMemberIds,
|
||||
routingFormResponseId: routing.responseId,
|
||||
teamMemberEmail: routing.teamMemberEmail,
|
||||
skipContactOwner: routing.skipContactOwner,
|
||||
crmAppSlug: routing.crmAppSlug,
|
||||
crmOwnerRecordType: routing.crmOwnerRecordType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -453,6 +467,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
location,
|
||||
},
|
||||
schedulingType: eventType.schedulingType,
|
||||
...this.getRoutingFormData(inputBooking.routing),
|
||||
});
|
||||
|
||||
switch (timeBetween) {
|
||||
|
||||
+418
-4
@@ -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())
|
||||
|
||||
+39
-1
@@ -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")
|
||||
|
||||
+23
@@ -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
-1
@@ -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],
|
||||
})
|
||||
|
||||
+136
@@ -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;
|
||||
}
|
||||
+203
-2
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
+3
-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: {},
|
||||
});
|
||||
|
||||
@@ -2542,6 +2542,126 @@
|
||||
"tags": [
|
||||
"Orgs / Routing forms"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "OrganizationsRoutingFormsResponsesController_createRoutingFormResponse",
|
||||
"summary": "Create routing form response and get available slots",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "orgId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "routingFormId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "start",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "\n Time starting from which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to start of day or specify hours:\n 2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z\n ",
|
||||
"example": "2050-09-05",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "end",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "\n Time until which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to end of day or specify hours:\n 2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z",
|
||||
"example": "2050-09-06",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "timeZone",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Time zone in which the available slots should be returned. Defaults to UTC.",
|
||||
"example": "Europe/Rome",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "duration",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "If event type has multiple possible durations then you can specify the desired duration here. Also, if you are fetching slots for a dynamic event then you can specify the duration her which defaults to 30, meaning that returned slots will be each 30 minutes long.",
|
||||
"example": "60",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Format of slot times in response. Use 'range' to get start and end times.",
|
||||
"example": "range",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"range",
|
||||
"time"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bookingUidToReschedule",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The unique identifier of the booking being rescheduled. When provided will ensure that the original booking time appears within the returned available slots when rescheduling.",
|
||||
"example": "abc123def456",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "queueResponse",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Whether to queue the form response.",
|
||||
"example": true,
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateRoutingFormResponseOutput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Orgs / Routing forms"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/routing-forms/{routingFormId}/responses/{responseId}": {
|
||||
@@ -23931,6 +24051,51 @@
|
||||
"timeZone"
|
||||
]
|
||||
},
|
||||
"Routing": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"responseId": {
|
||||
"type": "number",
|
||||
"description": "The ID of the routing form response that determined this booking assignment.",
|
||||
"example": 123
|
||||
},
|
||||
"teamMemberIds": {
|
||||
"description": "Array of team member IDs that were routed to handle this booking.",
|
||||
"example": [
|
||||
101,
|
||||
102
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"teamMemberEmail": {
|
||||
"type": "string",
|
||||
"description": "The email of the team member assigned to handle this booking.",
|
||||
"example": "john.doe@example.com"
|
||||
},
|
||||
"skipContactOwner": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to skip contact owner assignment from CRM integration.",
|
||||
"example": true
|
||||
},
|
||||
"crmAppSlug": {
|
||||
"type": "string",
|
||||
"description": "The CRM application slug for integration.",
|
||||
"example": "salesforce"
|
||||
},
|
||||
"crmOwnerRecordType": {
|
||||
"type": "string",
|
||||
"description": "The CRM owner record type for contact assignment.",
|
||||
"example": "Account"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"responseId",
|
||||
"teamMemberIds"
|
||||
]
|
||||
},
|
||||
"CreateBookingInput_2024_08_13": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24036,6 +24201,21 @@
|
||||
"type": "number",
|
||||
"example": 30,
|
||||
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
|
||||
},
|
||||
"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
|
||||
]
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Routing"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -24149,6 +24329,21 @@
|
||||
"example": 30,
|
||||
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
|
||||
},
|
||||
"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
|
||||
]
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Routing"
|
||||
}
|
||||
]
|
||||
},
|
||||
"instant": {
|
||||
"type": "boolean",
|
||||
"description": "Flag indicating if the booking is an instant booking. Only available for team events.",
|
||||
@@ -24267,6 +24462,21 @@
|
||||
"example": 30,
|
||||
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
|
||||
},
|
||||
"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
|
||||
]
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Routing"
|
||||
}
|
||||
]
|
||||
},
|
||||
"recurrenceCount": {
|
||||
"type": "number",
|
||||
"description": "The number of recurrences. If not provided then event type recurrence count will be used. Can't be more than\n event type recurrence count",
|
||||
@@ -26600,6 +26810,75 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"CreateRoutingFormResponseOutputData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"eventTypeId": {
|
||||
"type": "number",
|
||||
"description": "The ID of the event type that was routed to.",
|
||||
"example": 123
|
||||
},
|
||||
"routing": {
|
||||
"description": "The routing information.",
|
||||
"example": {
|
||||
"eventTypeId": 123,
|
||||
"routing": {
|
||||
"teamMemberIds": [
|
||||
101,
|
||||
102
|
||||
],
|
||||
"teamMemberEmail": "john.doe@example.com",
|
||||
"skipContactOwner": true
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Routing"
|
||||
}
|
||||
]
|
||||
},
|
||||
"routingCustomMessage": {
|
||||
"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."
|
||||
},
|
||||
"routingExternalRedirectUrl": {
|
||||
"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/"
|
||||
},
|
||||
"slots": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SlotsOutput_2024_09_04"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/RangeSlotsOutput_2024_09_04"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateRoutingFormResponseOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/CreateRoutingFormResponseOutputData"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"RequestEmailVerificationInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -14,11 +14,13 @@ export class ApiKeysRepositoryFixture {
|
||||
|
||||
async createApiKey(userId: number, expiresAt: Date | null, teamId?: number) {
|
||||
const keyString = randomBytes(16).toString("hex");
|
||||
const hashedKey = createHash("sha256").update(keyString).digest("hex");
|
||||
console.log("createApiKey Fixutre", { hashedKey, keyString });
|
||||
const apiKey = await this.prismaWriteClient.apiKey.create({
|
||||
data: {
|
||||
userId,
|
||||
teamId,
|
||||
hashedKey: createHash("sha256").update(keyString).digest("hex"),
|
||||
hashedKey,
|
||||
expiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2542,6 +2542,126 @@
|
||||
"tags": [
|
||||
"Orgs / Routing forms"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "OrganizationsRoutingFormsResponsesController_createRoutingFormResponse",
|
||||
"summary": "Create routing form response and get available slots",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "orgId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "routingFormId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "start",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "\n Time starting from which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to start of day or specify hours:\n 2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z\n ",
|
||||
"example": "2050-09-05",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "end",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "\n Time until which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to end of day or specify hours:\n 2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z",
|
||||
"example": "2050-09-06",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "timeZone",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Time zone in which the available slots should be returned. Defaults to UTC.",
|
||||
"example": "Europe/Rome",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "duration",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "If event type has multiple possible durations then you can specify the desired duration here. Also, if you are fetching slots for a dynamic event then you can specify the duration her which defaults to 30, meaning that returned slots will be each 30 minutes long.",
|
||||
"example": "60",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Format of slot times in response. Use 'range' to get start and end times.",
|
||||
"example": "range",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"range",
|
||||
"time"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bookingUidToReschedule",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The unique identifier of the booking being rescheduled. When provided will ensure that the original booking time appears within the returned available slots when rescheduling.",
|
||||
"example": "abc123def456",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "queueResponse",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Whether to queue the form response.",
|
||||
"example": true,
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateRoutingFormResponseOutput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Orgs / Routing forms"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/routing-forms/{routingFormId}/responses/{responseId}": {
|
||||
@@ -23931,6 +24051,51 @@
|
||||
"timeZone"
|
||||
]
|
||||
},
|
||||
"Routing": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"responseId": {
|
||||
"type": "number",
|
||||
"description": "The ID of the routing form response that determined this booking assignment.",
|
||||
"example": 123
|
||||
},
|
||||
"teamMemberIds": {
|
||||
"description": "Array of team member IDs that were routed to handle this booking.",
|
||||
"example": [
|
||||
101,
|
||||
102
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"teamMemberEmail": {
|
||||
"type": "string",
|
||||
"description": "The email of the team member assigned to handle this booking.",
|
||||
"example": "john.doe@example.com"
|
||||
},
|
||||
"skipContactOwner": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to skip contact owner assignment from CRM integration.",
|
||||
"example": true
|
||||
},
|
||||
"crmAppSlug": {
|
||||
"type": "string",
|
||||
"description": "The CRM application slug for integration.",
|
||||
"example": "salesforce"
|
||||
},
|
||||
"crmOwnerRecordType": {
|
||||
"type": "string",
|
||||
"description": "The CRM owner record type for contact assignment.",
|
||||
"example": "Account"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"responseId",
|
||||
"teamMemberIds"
|
||||
]
|
||||
},
|
||||
"CreateBookingInput_2024_08_13": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24036,6 +24201,21 @@
|
||||
"type": "number",
|
||||
"example": 30,
|
||||
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
|
||||
},
|
||||
"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
|
||||
]
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Routing"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -24149,6 +24329,21 @@
|
||||
"example": 30,
|
||||
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
|
||||
},
|
||||
"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
|
||||
]
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Routing"
|
||||
}
|
||||
]
|
||||
},
|
||||
"instant": {
|
||||
"type": "boolean",
|
||||
"description": "Flag indicating if the booking is an instant booking. Only available for team events.",
|
||||
@@ -24267,6 +24462,21 @@
|
||||
"example": 30,
|
||||
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
|
||||
},
|
||||
"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
|
||||
]
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Routing"
|
||||
}
|
||||
]
|
||||
},
|
||||
"recurrenceCount": {
|
||||
"type": "number",
|
||||
"description": "The number of recurrences. If not provided then event type recurrence count will be used. Can't be more than\n event type recurrence count",
|
||||
@@ -26600,6 +26810,75 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"CreateRoutingFormResponseOutputData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"eventTypeId": {
|
||||
"type": "number",
|
||||
"description": "The ID of the event type that was routed to.",
|
||||
"example": 123
|
||||
},
|
||||
"routing": {
|
||||
"description": "The routing information.",
|
||||
"example": {
|
||||
"eventTypeId": 123,
|
||||
"routing": {
|
||||
"teamMemberIds": [
|
||||
101,
|
||||
102
|
||||
],
|
||||
"teamMemberEmail": "john.doe@example.com",
|
||||
"skipContactOwner": true
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Routing"
|
||||
}
|
||||
]
|
||||
},
|
||||
"routingCustomMessage": {
|
||||
"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."
|
||||
},
|
||||
"routingExternalRedirectUrl": {
|
||||
"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/"
|
||||
},
|
||||
"slots": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SlotsOutput_2024_09_04"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/RangeSlotsOutput_2024_09_04"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateRoutingFormResponseOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/CreateRoutingFormResponseOutputData"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"RequestEmailVerificationInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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