From 5a3f15357c0dce4a7d558da6aa52d7b0f3e0d7cc Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:28:07 +0200 Subject: [PATCH] fix: filter slots by requested date range to prevent date override leakage (#25822) * fix: filter slots by requested date range to prevent date override leakage Co-Authored-By: morgan@cal.com * refactor: move filterSlotsByRequestedDateRange to private method Co-Authored-By: morgan@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../trpc/server/routers/viewer/slots/util.ts | 66 ++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 5f9346b11a..a64b37cc38 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -272,6 +272,61 @@ export class AvailableSlotsService { return periodType === PeriodType.ROLLING_WINDOW || periodType === PeriodType.ROLLING; } + /** + * Filters slots to only include dates within the requested range. + * This is necessary because buildDateRanges uses a ±1 day buffer when checking + * if date overrides should be included (to handle timezone edge cases), which can + * cause slots from adjacent days to leak into the response. + */ + private _filterSlotsByRequestedDateRange< + T extends Record, + >({ + slotsMappedToDate, + startTime, + endTime, + timeZone, + }: { + slotsMappedToDate: T; + startTime: string; + endTime: string; + timeZone: string | undefined; + }): T { + if (!timeZone) { + return slotsMappedToDate; + } + const inputStartTime = dayjs(startTime).tz(timeZone); + const inputEndTime = dayjs(endTime).tz(timeZone); + + // fr-CA uses YYYY-MM-DD format + const formatter = new Intl.DateTimeFormat("fr-CA", { + year: "numeric", + month: "2-digit", + day: "2-digit", + timeZone: timeZone, + }); + + const allowedDates = new Set(); + for ( + let d = inputStartTime.startOf("day"); + !d.isAfter(inputEndTime, "day"); + d = d.add(1, "day") + ) { + allowedDates.add(formatter.format(d.toDate())); + } + + const filtered = {} as T; + for (const [date, slots] of Object.entries(slotsMappedToDate)) { + if (allowedDates.has(date)) { + (filtered as Record)[date] = slots; + } + } + return filtered; + } + private filterSlotsByRequestedDateRange = withReporting( + this._filterSlotsByRequestedDateRange.bind(this), + "filterSlotsByRequestedDateRange" + ); + private _getAllDatesWithBookabilityStatus(availableDates: string[]) { const availableDatesSet = new Set(availableDates); const firstDate = dayjs(availableDates[0]); @@ -1431,8 +1486,15 @@ export class AvailableSlotsService { ); const withinBoundsSlotsMappedToDate = mapWithinBoundsSlotsToDate(); + const filteredSlotsMappedToDate = this.filterSlotsByRequestedDateRange({ + slotsMappedToDate: withinBoundsSlotsMappedToDate, + startTime: input.startTime, + endTime: input.endTime, + timeZone: input.timeZone, + }); + // We only want to run this on single targeted events and not dynamic - if (!Object.keys(withinBoundsSlotsMappedToDate).length && input.usernameList?.length === 1) { + if (!Object.keys(filteredSlotsMappedToDate).length && input.usernameList?.length === 1) { try { await this.dependencies.noSlotsNotificationService.handleNotificationWhenNoSlots({ eventDetails: { @@ -1474,7 +1536,7 @@ export class AvailableSlotsService { : null; return { - slots: withinBoundsSlotsMappedToDate, + slots: filteredSlotsMappedToDate, ...troubleshooterData, }; }