diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index d93324eb72..8f0d567894 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -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": "*", diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts index e6c19b2613..c0625d0e83 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts @@ -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) { diff --git a/apps/api/v2/src/modules/organizations/routing-forms/controllers/organizations-routing-forms-responses.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/routing-forms/controllers/organizations-routing-forms-responses.controller.e2e-spec.ts index ac06a57e3d..e45bf5a208 100644 --- a/apps/api/v2/src/modules/organizations/routing-forms/controllers/organizations-routing-forms-responses.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/routing-forms/controllers/organizations-routing-forms-responses.controller.e2e-spec.ts @@ -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()) diff --git a/apps/api/v2/src/modules/organizations/routing-forms/controllers/organizations-routing-forms-responses.controller.ts b/apps/api/v2/src/modules/organizations/routing-forms/controllers/organizations-routing-forms-responses.controller.ts index 0909994c89..2cef0243f1 100644 --- a/apps/api/v2/src/modules/organizations/routing-forms/controllers/organizations-routing-forms-responses.controller.ts +++ b/apps/api/v2/src/modules/organizations/routing-forms/controllers/organizations-routing-forms-responses.controller.ts @@ -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 { + 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") diff --git a/apps/api/v2/src/modules/organizations/routing-forms/inputs/create-routing-form-response.input.ts b/apps/api/v2/src/modules/organizations/routing-forms/inputs/create-routing-form-response.input.ts new file mode 100644 index 0000000000..40847bda57 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/routing-forms/inputs/create-routing-form-response.input.ts @@ -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; +} \ No newline at end of file diff --git a/apps/api/v2/src/modules/organizations/routing-forms/organizations-routing-forms.module.ts b/apps/api/v2/src/modules/organizations/routing-forms/organizations-routing-forms.module.ts index cf458bbcba..023d40fe4c 100644 --- a/apps/api/v2/src/modules/organizations/routing-forms/organizations-routing-forms.module.ts +++ b/apps/api/v2/src/modules/organizations/routing-forms/organizations-routing-forms.module.ts @@ -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], }) diff --git a/apps/api/v2/src/modules/organizations/routing-forms/outputs/create-routing-form-response.output.ts b/apps/api/v2/src/modules/organizations/routing-forms/outputs/create-routing-form-response.output.ts new file mode 100644 index 0000000000..dd76a6ecfc --- /dev/null +++ b/apps/api/v2/src/modules/organizations/routing-forms/outputs/create-routing-form-response.output.ts @@ -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; +} \ No newline at end of file diff --git a/apps/api/v2/src/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service.ts b/apps/api/v2/src/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service.ts index 33a612604e..dbe859e7d3 100644 --- a/apps/api/v2/src/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service.ts +++ b/apps/api/v2/src/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service.ts @@ -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 { + 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]; + } } diff --git a/apps/api/v2/src/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module.ts b/apps/api/v2/src/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module.ts index be2bb9c2f2..396dc07e96 100644 --- a/apps/api/v2/src/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module.ts +++ b/apps/api/v2/src/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module.ts @@ -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, diff --git a/apps/api/v2/src/modules/router/controllers/router.controller.ts b/apps/api/v2/src/modules/router/controllers/router.controller.ts index a615b32bb5..ff25c2fe7c 100644 --- a/apps/api/v2/src/modules/router/controllers/router.controller.ts +++ b/apps/api/v2/src/modules/router/controllers/router.controller.ts @@ -25,9 +25,9 @@ export class RouterController { @Param("formId") formId: string, @Body() body?: Record ): Promise & { 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 & { 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")); diff --git a/apps/api/v2/src/modules/routing-forms/services/routing-forms.service.ts b/apps/api/v2/src/modules/routing-forms/services/routing-forms.service.ts index 045a559c62..136a05507c 100644 --- a/apps/api/v2/src/modules/routing-forms/services/routing-forms.service.ts +++ b/apps/api/v2/src/modules/routing-forms/services/routing-forms.service.ts @@ -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, diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.ts index ec4e7a72a9..9cc326376c 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots-input.service.ts @@ -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, }; } diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts index 2f908ca934..898f30b85f 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/services/slots.service.ts @@ -47,6 +47,7 @@ export class SlotsService_2024_09_04 { const availableSlots: TimeSlots = await getAvailableSlots({ input: { ...queryTransformed, + routingFormResponseId: queryTransformed.routingFormResponseId ?? undefined, }, ctx: {}, }); diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index f49e4ffeda..2b208e384f 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -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 ` where `` 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": { diff --git a/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts index 117fa0aeb6..9958253a40 100644 --- a/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts @@ -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, }, }); diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index f49e4ffeda..2b208e384f 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -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 ` where `` 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": { diff --git a/packages/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail.ts b/packages/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail.ts index 86fccf7c18..9d2ddd8003 100644 --- a/packages/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail.ts +++ b/packages/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail.ts @@ -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["response"]; + identifierKeyedResponse: Record | 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; } diff --git a/packages/app-store/routing-forms/lib/handleResponse.test.ts b/packages/app-store/routing-forms/lib/handleResponse.test.ts index 4c18bca224..d7fa9b40c1 100644 --- a/packages/app-store/routing-forms/lib/handleResponse.test.ts +++ b/packages/app-store/routing-forms/lib/handleResponse.test.ts @@ -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, diff --git a/packages/app-store/routing-forms/lib/handleResponse.ts b/packages/app-store/routing-forms/lib/handleResponse.ts index bd3edf6395..43db0e5539 100644 --- a/packages/app-store/routing-forms/lib/handleResponse.ts +++ b/packages/app-store/routing-forms/lib/handleResponse.ts @@ -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["response"]; + identifierKeyedResponse: Record | 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; diff --git a/packages/lib/server/getRoutedUrl.ts b/packages/lib/server/getRoutedUrl.ts index 1b3db54562..3b73d22ed5 100644 --- a/packages/lib/server/getRoutedUrl.ts +++ b/packages/lib/server/getRoutedUrl.ts @@ -49,7 +49,8 @@ export function hasEmbedPath(pathWithQuery: string) { return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/"); } -const _getRoutedUrl = async (context: Pick) => { +// 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, 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 Routing) + routing?: Routing; } export class CreateInstantBookingInput_2024_08_13 extends CreateBookingInput_2024_08_13 { diff --git a/packages/platform/types/slots/slots-2024-09-04/inputs/get-slots.input.ts b/packages/platform/types/slots/slots-2024-09-04/inputs/get-slots.input.ts index e22265fc31..e611ad8749 100644 --- a/packages/platform/types/slots/slots-2024-09-04/inputs/get-slots.input.ts +++ b/packages/platform/types/slots/slots-2024-09-04/inputs/get-slots.input.ts @@ -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"; diff --git a/packages/trpc/server/routers/viewer/routing-forms/response.handler.ts b/packages/trpc/server/routers/viewer/routing-forms/response.handler.ts index fa1320c86f..76aeeaf40d 100644 --- a/packages/trpc/server/routers/viewer/routing-forms/response.handler.ts +++ b/packages/trpc/server/routers/viewer/routing-forms/response.handler.ts @@ -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;