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 <morgan@cal.com>

* refactor: move filterSlotsByRequestedDateRange to private method

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Morgan
2025-12-12 11:28:07 -03:00
committed by GitHub
co-authored by morgan@cal.com Devin AI
parent e46c1bb40c
commit 5a3f15357c
@@ -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<string, { time: string; attendees?: number; bookingUid?: string }[]>,
>({
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<string>();
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<string, typeof slots>)[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,
};
}