fix: Allow zero routingFormResponseId in API v2 available endpoint (#21301)

* Allow zero routingFormResponseId

* fix: conditionally validate routingFormResponseId to allow 0 only for dry run operations

Co-Authored-By: hariom@cal.com <hariom@cal.com>

* fix: pass _isDryRun parameter to API v2 available endpoint

Co-Authored-By: hariom@cal.com <hariom@cal.com>

* fix runtime error and improve message

* Add e2e test to verify the constraint works

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
This commit is contained in:
Hariom Balhara
2025-05-16 12:23:11 +05:30
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> hariom@cal.com <hariom@cal.com>
parent 5bc9d71a88
commit fab364f084
3 changed files with 115 additions and 1 deletions
@@ -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);
@@ -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 = {
@@ -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()