chore: slots time range validation to zod (#22942)

* chore: Move date range validation to zod, removing TRPCError

* This is not a BAD_REQUEST but an internal error, should not happen

* Add a check to ensure the given start time is before end time
This commit is contained in:
Alex van Andel authored and GitHub committed 2025-08-07 15:46:34 +03:00
1 parent 33aedbae20
commit cabd459682
2 files changed
+49 -61

No files matched your search

@@ -3,40 +3,45 @@ import { z } from "zod";
import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema";
export const getScheduleSchema = z
.object({
// startTime ISOString
startTime: z.string(),
// endTime ISOString
endTime: z.string(),
// Event type ID
eventTypeId: z.coerce.number().int().optional(),
// Event type slug
eventTypeSlug: z.string().optional(),
// invitee timezone
timeZone: timeZoneSchema.optional(),
// or list of users (for dynamic events)
usernameList: z.array(z.string()).min(1).optional(),
debug: z.boolean().optional(),
// to handle event types with multiple duration options
duration: z
.string()
.optional()
.transform((val) => val && parseInt(val)),
rescheduleUid: z.string().nullish(),
// whether to do team event or user event
isTeamEvent: z.boolean().optional().default(false),
orgSlug: z.string().nullish(),
teamMemberEmail: z.string().nullish(),
routedTeamMemberIds: z.array(z.number()).nullish(),
skipContactOwner: z.boolean().nullish(),
_enableTroubleshooter: z.boolean().optional(),
_bypassCalendarBusyTimes: z.boolean().optional(),
_shouldServeCache: z.boolean().optional(),
routingFormResponseId: z.number().optional(),
queuedFormResponseId: z.string().nullish(),
email: z.string().nullish(),
})
const isValidDateString = (val: string) => !isNaN(Date.parse(val));
export const getScheduleSchemaObject = z.object({
startTime: z.string().refine(isValidDateString, {
message: "startTime must be a valid date string",
}),
endTime: z.string().refine(isValidDateString, {
message: "endTime must be a valid date string",
}),
// Event type ID
eventTypeId: z.coerce.number().int().optional(),
// Event type slug
eventTypeSlug: z.string().optional(),
// invitee timezone
timeZone: timeZoneSchema.optional(),
// or list of users (for dynamic events)
usernameList: z.array(z.string()).min(1).optional(),
debug: z.boolean().optional(),
// to handle event types with multiple duration options
duration: z
.string()
.optional()
.transform((val) => val && parseInt(val)),
rescheduleUid: z.string().nullish(),
// whether to do team event or user event
isTeamEvent: z.boolean().optional().default(false),
orgSlug: z.string().nullish(),
teamMemberEmail: z.string().nullish(),
routedTeamMemberIds: z.array(z.number()).nullish(),
skipContactOwner: z.boolean().nullish(),
_enableTroubleshooter: z.boolean().optional(),
_bypassCalendarBusyTimes: z.boolean().optional(),
_shouldServeCache: z.boolean().optional(),
routingFormResponseId: z.number().optional(),
queuedFormResponseId: z.string().nullish(),
email: z.string().nullish(),
});
export const getScheduleSchema = getScheduleSchemaObject
.transform((val) => {
// Need this so we can pass a single username in the query string form public API
if (val.usernameList) {
@@ -50,7 +55,11 @@ export const getScheduleSchema = z
.refine(
(data) => !!data.eventTypeId || (!!data.usernameList && !!data.eventTypeSlug),
"You need to either pass an eventTypeId OR an usernameList/eventTypeSlug combination"
);
)
.refine(({ startTime, endTime }) => new Date(endTime).getTime() > new Date(startTime).getTime(), {
message: "endTime must be after startTime",
path: ["endTime"],
});
export const reserveSlotSchema = z
.object({
@@ -82,7 +91,7 @@ export interface ContextForGetSchedule extends Record<string, unknown> {
req?: (IncomingMessage & { cookies: Partial<{ [key: string]: string }> }) | undefined;
}
export type TGetScheduleInputSchema = z.infer<typeof getScheduleSchema>;
export type TGetScheduleInputSchema = z.infer<typeof getScheduleSchemaObject>;
export const ZGetScheduleInputSchema = getScheduleSchema;
export type GetScheduleOptions = {
@@ -186,10 +186,8 @@ export class AvailableSlotsService {
const { currentOrgDomain, isValidOrgDomain } = organizationDetails;
// For dynamic booking, we need to get and update user credentials, schedule and availability in the eventTypeObject as they're required in the new availability logic
if (!input.eventTypeSlug) {
throw new TRPCError({
message: "eventTypeSlug is required for dynamic booking",
code: "BAD_REQUEST",
});
// never happens as it's guarded by our Zod Schema refine, but for clear type safety we throw an Error if the eventTypeSlug isn't given.
throw new Error("Event type slug is required in dynamic booking.");
}
const dynamicEventType = getDefaultEvent(input.eventTypeSlug);
@@ -634,19 +632,6 @@ export class AvailableSlotsService {
limitManager.mergeBusyTimes(globalLimitManager);
const bookingLimitsParams = {
bookings: userBusyTimes,
bookingLimits,
dateFrom,
dateTo,
limitManager,
rescheduleUid,
teamId,
user,
includeManagedEvents,
timeZone,
};
for (const key of descendingLimitKeys) {
const limit = bookingLimits?.[key];
if (!limit) continue;
@@ -1011,10 +996,6 @@ export class AvailableSlotsService {
);
const endTime =
input.timeZone === "Etc/GMT" ? dayjs.utc(input.endTime) : dayjs(input.endTime).utc().tz(input.timeZone);
if (!startTime.isValid() || !endTime.isValid()) {
throw new TRPCError({ message: "Invalid time range given.", code: "BAD_REQUEST" });
}
// when an empty array is given we should prefer to have it handled as if this wasn't given at all
// we don't want to return no availability in this case.
const routedTeamMemberIds = input.routedTeamMemberIds ?? [];
@@ -1164,11 +1145,9 @@ export class AvailableSlotsService {
scheduleId: eventType.restrictionScheduleId,
});
if (restrictionSchedule) {
// runtime error preventing misconfiguration when restrictionSchedule timeZone must be used.
if (!eventType.useBookerTimezone && !restrictionSchedule.timeZone) {
throw new TRPCError({
message: "No timezone is set for the restricted schedule",
code: "BAD_REQUEST",
});
throw new Error("No timezone is set for the restricted schedule");
}
const restrictionTimezone = eventType.useBookerTimezone