diff --git a/apps/api/v2/src/modules/slots/slots-2024-04-15/controllers/slots.controller.e2e-spec.ts b/apps/api/v2/src/modules/slots/slots-2024-04-15/controllers/slots.controller.e2e-spec.ts index 3923b63d6d..15e1311ba6 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-04-15/controllers/slots.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-04-15/controllers/slots.controller.e2e-spec.ts @@ -685,6 +685,86 @@ describe("Slots 2024-04-15 Endpoints", () => { await bookingsRepositoryFixture.deleteById(booking.id); }); + describe("routingFormResponseId and _isDryRun validation", () => { + const baseUrl = "/api/v2/slots/available"; + const baseParams = { + startTime: "2050-09-05", + endTime: "2050-09-10", + }; + + it("should allow routingFormResponseId=0 in dry-run mode", async () => { + return request(app.getHttpServer()) + .get(baseUrl) + .query({ + ...baseParams, + eventTypeId, + routingFormResponseId: 0, + _isDryRun: true, + }) + .expect(200) + .expect((res) => { + expect(res.body.status).toEqual(SUCCESS_STATUS); + }); + }); + + it("should reject routingFormResponseId=1 in dry-run mode", async () => { + return request(app.getHttpServer()) + .get(baseUrl) + .query({ + ...baseParams, + eventTypeId, + routingFormResponseId: 1, + _isDryRun: true, + }) + .expect(400) + .expect((res) => { + expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain("routingFormResponseId must be 0 for dry run"); + }); + }); + + it("should allow routingFormResponseId=1 in non-dry-run mode", async () => { + return request(app.getHttpServer()) + .get(baseUrl) + .query({ + ...baseParams, + eventTypeId, + routingFormResponseId: 1, + }) + .expect(200) + .expect((res) => { + expect(res.body.status).toEqual(SUCCESS_STATUS); + }); + }); + + it("should reject routingFormResponseId=0 in non-dry-run mode", async () => { + return request(app.getHttpServer()) + .get(baseUrl) + .query({ + ...baseParams, + eventTypeId, + routingFormResponseId: 0, + }) + .expect(400) + .expect((res) => { + expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain("routingFormResponseId must be a positive number"); + }); + }); + + it("should reject routingFormResponseId=-1 in non-dry-run mode", async () => { + return request(app.getHttpServer()) + .get(baseUrl) + .query({ + ...baseParams, + eventTypeId, + routingFormResponseId: -1, + }) + .expect(400) + .expect((res) => { + expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain("routingFormResponseId must be a positive number"); + }); + }); + }); + afterAll(async () => { await userRepositoryFixture.deleteByEmail(user.email); await selectedSlotsRepositoryFixture.deleteByUId(reservedSlotUid); diff --git a/packages/features/schedules/lib/use-schedule/useSchedule.ts b/packages/features/schedules/lib/use-schedule/useSchedule.ts index 6dd1afad25..ea6446e8b0 100644 --- a/packages/features/schedules/lib/use-schedule/useSchedule.ts +++ b/packages/features/schedules/lib/use-schedule/useSchedule.ts @@ -2,6 +2,7 @@ import { useSearchParams } from "next/navigation"; import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe"; import { useBookerStore } from "@calcom/features/bookings/Booker/store"; +import { isBookingDryRun } from "@calcom/features/bookings/Booker/utils/isBookingDryRun"; import { useTimesForSchedule } from "@calcom/features/schedules/lib/use-schedule/useTimesForSchedule"; import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams"; import { PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS } from "@calcom/lib/constants"; @@ -97,6 +98,7 @@ export const useSchedule = ({ email, // Ensures that connectVersion causes a refresh of the data ...(embedConnectVersion ? { embedConnectVersion } : {}), + _isDryRun: searchParams ? isBookingDryRun(searchParams) : false, }; const options = { diff --git a/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts b/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts index cb2b13cc9c..622bb4a045 100644 --- a/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts +++ b/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional, ApiHideProperty } from "@nestjs/swagger"; import { Transform } from "class-transformer"; +import type { ValidationArguments, ValidatorConstraintInterface } from "class-validator"; import { IsArray, IsBoolean, @@ -10,10 +11,35 @@ import { IsString, Min, IsEnum, + ValidatorConstraint, + Validate, } from "class-validator"; import { SlotFormat } from "@calcom/platform-enums"; +@ValidatorConstraint({ name: "routingFormResponseIdValidator", async: false }) +class RoutingFormResponseIdValidator implements ValidatorConstraintInterface { + validate(routingFormResponseId: number, args: ValidationArguments) { + if (routingFormResponseId === undefined) return true; + + const payload = args.object as GetAvailableSlotsInput_2024_04_15; + + if (payload._isDryRun) { + return routingFormResponseId === 0; + } + + return routingFormResponseId >= 1; + } + + defaultMessage(args: ValidationArguments) { + const payload = args.object as GetAvailableSlotsInput_2024_04_15; + if (payload._isDryRun) { + return "routingFormResponseId must be 0 for dry run"; + } + return "routingFormResponseId must be a positive number"; + } +} + export class GetAvailableSlotsInput_2024_04_15 { @IsDateString({ strict: true }) @ApiProperty({ @@ -145,7 +171,7 @@ export class GetAvailableSlotsInput_2024_04_15 { @Transform(({ value }: { value: string }) => value && parseInt(value)) @IsNumber() @IsOptional() - @Min(1, { message: "routingFormResponseId must be a positive number" }) + @Validate(RoutingFormResponseIdValidator) @ApiPropertyOptional() @ApiHideProperty() routingFormResponseId?: number; @@ -156,6 +182,12 @@ export class GetAvailableSlotsInput_2024_04_15 { @ApiHideProperty() _shouldServeCache?: boolean; + @Transform(({ value }) => value && value.toLowerCase() === "true") + @IsBoolean() + @IsOptional() + @ApiHideProperty() + _isDryRun?: boolean; + @Transform(({ value }) => value && value.toLowerCase() === "true") @IsBoolean() @IsOptional()