Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0b1878f
fix(reschedule): respect availability when rescheduling (#16378)
sujal12344 Sep 28, 2025
20ac00f
Merge branch 'main' into fix/16378-reschedule-availability
sujal12344 Sep 29, 2025
357c0a0
Merge branch 'main' into fix/16378-reschedule-availability
sujal12344 Sep 29, 2025
d4181e3
fix(slots-reschedule-test-case): fixed rescheduling logic for test cases
sujal12344 Sep 29, 2025
ce1c5fc
Merge branch 'calcom:main' into fix/16378-reschedule-availability
sujal12344 Sep 29, 2025
78f9060
Merge branch 'main' into fix/16378-reschedule-availability
sujal12344 Sep 29, 2025
d24625e
fixed type check and farmat
sujal12344 Sep 29, 2025
c64e312
Merge branch 'main' into fix/16378-reschedule-availability
sujal12344 Sep 29, 2025
d46caf6
Merge branch 'main' into fix/16378-reschedule-availability
sujal12344 Sep 29, 2025
3bcd70a
Merge branch 'main' into fix/16378-reschedule-availability
sujal12344 Sep 30, 2025
6b112e5
Merge branch 'main' into fix/16378-reschedule-availability
sujal12344 Sep 30, 2025
2fcd94c
Merge branch 'main' into fix/16378-reschedule-availability
sujal12344 Oct 10, 2025
8d234a7
fix conflict error
aayush-dev-tsx Oct 10, 2025
dc180e3
Merge branch 'main' into fix/16378-reschedule-availability
sujal12344 Oct 15, 2025
6b3d61b
Merge remote-tracking branch 'upstream/main' into fix/16378-reschedul…
sujal12344 Oct 18, 2025
1ec6c9c
Merge branch 'main' into fix/16378-reschedule-availability
sujal12344 Oct 23, 2025
0193d02
Merge branch 'main' into fix/16378-reschedule-availability
sujal12344 Oct 23, 2025
df6c222
Merge branch 'main' into fix/16378-reschedule-availability
sujal12344 Oct 24, 2025
e36a055
fix: path changed
sujal12344 Oct 24, 2025
285ff33
Merge branch 'main' into fix/16378-reschedule-availability
romitg2 Feb 3, 2026
f92e681
Merge branch 'main' into fix/16378-reschedule-availability
sujal12344 Feb 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 260 additions & 0 deletions apps/web/test/lib/availability-intersection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import {
createBookingScenario,
getOrganizer,
getScenarioData,
TestData,
} from "../utils/bookingScenario/bookingScenario";

import { describe, expect, test, vi } from "vitest";

import dayjs from "@calcom/dayjs";
import { getAvailableSlotsService } from "@calcom/features/di/containers/AvailableSlots";

import { setupAndTeardown } from "./getSchedule/setupAndTeardown";

describe("User Availability Intersection Tests", () => {
const availableSlotsService = getAvailableSlotsService();
// console.log({availableSlotsService})
setupAndTeardown();

test("should show intersection of session user and organizer availability", async () => {
vi.setSystemTime("2024-05-20T00:00:00Z");

const organizer = getOrganizer({
name: "aayush",
email: "aayush@example.com",
id: 101,
username: "aayush",
defaultScheduleId: 1,
credentials: [],
selectedCalendars: [],
schedules: [
{
id: 1,
name: "Working Time",
availability: [
{
days: [1, 2, 3, 4, 5, 6, 7], // Monday to Sunday
startTime: new Date("1970-01-01T10:00:00.000Z"), // 10 AM
endTime: new Date("1970-01-01T14:00:00.000Z"), // 2 PM
date: null,
},
],
timeZone: "Asia/Kolkata",
},
],
});

const sessionUser = {
...TestData.users.example,
id: 102,
email: "sujal@example.com",
username: "sujal",
name: "sujal",
defaultScheduleId: 2,
timeZone: "Asia/Kolkata",
schedules: [
{
id: 2,
name: "My Available Time",
availability: [
{
days: [1, 2, 3, 4, 5, 6, 7], // Monday to Sunday
startTime: new Date("1970-01-01T08:00:00.000Z"), // 8 AM
endTime: new Date("1970-01-01T11:00:00.000Z"), // 11 AM
date: null,
},
],
timeZone: "Asia/Kolkata",
},
],
};

const scenarioData = getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: undefined,
length: 30,
users: [{ id: 101 }],
title: "Meeting with Aayush",
slug: "meeting-with-aayush",
},
],
users: [organizer, sessionUser],
bookings: [
{
id: 1,
uid: "test-reschedule-uid",
eventTypeId: 1,
title: "Existing Meeting",
startTime: new Date("2024-05-21T05:00:00.000Z").toString(), // 10:30 AM IST
endTime: new Date("2024-05-21T05:30:00.000Z").toString(), // 11:00 AM IST
attendees: [
{
email: "sujal@example.com",
bookingSeat: {
referenceUid: "test-reschedule-uid",
data: "some-data",
},
},
{
email: "aayush@example.com",
bookingSeat: {
referenceUid: "test-reschedule-uid",
data: "some-data",
},
},
],
status: "ACCEPTED",
},
],
});

await createBookingScenario(scenarioData);

// Get slots for organizer's event with reschedule context
const slots = await availableSlotsService.getAvailableSlots({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: "2024-05-21T00:00:00.000Z",
endTime: "2024-05-22T23:59:59.999Z",
timeZone: "Asia/Kolkata",
isTeamEvent: false,
usernameList: ["aayush"],
orgSlug: undefined,
rescheduleUid: "test-reschedule-uid",
email: "sujal@example.com", // Current user trying to reschedule
},
});

// The intersection should be 10 AM to 11 AM (1 hour)
// Since organizer is available 10 AM - 2 PM and session user is available 8 AM - 11 AM
// The intersection should only show slots between 10 AM - 11 AM

const slotsForDate = slots.slots["2024-05-21"] || [];

// Convert UTC times to IST for proper comparison
// Slots are in UTC, need to convert to IST (UTC + 5:30)
const intersectionSlots = slotsForDate.filter((slot) => {
const slotTime = dayjs(slot.time).utc().add(5.5, "hours"); // Convert UTC to IST
const slotHour = slotTime.hour();
// In IST, 10 AM - 11 AM should be available (intersection window)
return slotHour >= 10 && slotHour < 11;
});

// Check that no slots exist outside the intersection
const outsideIntersectionSlots = slotsForDate.filter((slot) => {
const slotTime = dayjs(slot.time).utc().add(5.5, "hours"); // Convert UTC to IST
const slotHour = slotTime.hour();
// No slots should be available before 10 AM or after 11 AM in IST
return slotHour < 10 || slotHour >= 11;
});
// console.log({
// slotsForDate: slotsForDate.map(s => ({
// utc: s.time,
// ist: dayjs(s.time).utc().add(5.5, 'hours').format('YYYY-MM-DD HH:mm:ss')
// })),
// outsideIntersectionSlots: outsideIntersectionSlots.length,
// intersectionSlots: intersectionSlots.length
// });

expect(intersectionSlots.length).toBeGreaterThan(0);
expect(outsideIntersectionSlots.length).toBe(0);

console.log(
"Intersection slots found:",
intersectionSlots.map((s) => s.time)
);
console.log("Total slots (should only be in intersection):", slotsForDate.length);
});

test("should show organizer's full availability when no session user", async () => {
vi.setSystemTime("2024-05-20T00:00:00Z");

const organizer = getOrganizer({
name: "aayush",
email: "aayush@example.com",
id: 101,
username: "aayush",
defaultScheduleId: 1,
credentials: [],
selectedCalendars: [],
schedules: [
{
id: 1,
name: "Working Time",
availability: [
{
days: [1, 2, 3, 4, 5, 6, 7], // Monday to Sunday
startTime: new Date("1970-01-01T10:00:00.000Z"), // 10 AM
endTime: new Date("1970-01-01T14:00:00.000Z"), // 2 PM
date: null,
},
],
timeZone: "Asia/Kolkata",
},
],
});

const scenarioData = getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: undefined,
length: 30,
users: [{ id: 101 }],
title: "Meeting with Aayush",
slug: "meeting-with-aayush",
},
],
users: [organizer],
});

await createBookingScenario(scenarioData);

const slots = await availableSlotsService.getAvailableSlots({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: "2024-05-21T00:00:00.000Z",
endTime: "2024-05-22T23:59:59.999Z",
timeZone: "Asia/Kolkata",
isTeamEvent: false,
usernameList: ["aayush"],
orgSlug: undefined,
},
});

const slotsForDate = slots.slots["2024-05-21"] || [];

// Should show full organizer availability (10 AM - 2 PM IST)
// Convert UTC to IST for comparison
const availableSlots = slotsForDate.filter((slot) => {
const slotTime = dayjs(slot.time).utc().add(5.5, "hours"); // Convert UTC to IST
const slotHour = slotTime.hour();
return slotHour >= 10 && slotHour < 14; // 10 AM - 2 PM IST
});

expect(availableSlots.length).toBeGreaterThan(0);

// Should have slots throughout the full availability window
const morningSlots = slotsForDate.filter((slot) => {
const slotTime = dayjs(slot.time).utc().add(5.5, "hours"); // Convert UTC to IST
const slotHour = slotTime.hour();
return slotHour >= 10 && slotHour < 12; // 10 AM - 12 PM IST
});

const afternoonSlots = slotsForDate.filter((slot) => {
const slotTime = dayjs(slot.time).utc().add(5.5, "hours"); // Convert UTC to IST
const slotHour = slotTime.hour();
return slotHour >= 12 && slotHour < 14; // 12 PM - 2 PM IST
});

expect(morningSlots.length).toBeGreaterThan(0);
expect(afternoonSlots.length).toBeGreaterThan(0);

console.log("Full organizer availability slots:", slotsForDate.length);
});
});
125 changes: 125 additions & 0 deletions packages/trpc/server/routers/viewer/slots/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,54 @@ export class AvailableSlotsService {
}
: orgDomainConfig(ctx?.req);

// Extract current session user from context or from reschedule/booking context
let currentSessionUser: { id: number; email: string } | null = null;

// First, try to get user from reschedule context (for reschedule scenarios)
if (input.rescheduleUid && input.email) {
// When rescheduling, the email in input represents the user who needs intersection
// This could be either the original booker or invitee
try {
const userRepo = this.dependencies.userRepo;
const rescheduleUser = await userRepo.findByEmail({ email: input.email });
if (rescheduleUser?.id) {
currentSessionUser = {
id: rescheduleUser.id,
email: rescheduleUser.email,
};
log.debug("Found reschedule user for intersection", { userId: rescheduleUser.id });
}
} catch (error) {
log.debug("Failed to find reschedule user", { email: input.email, error });
}
}

// If no reschedule user found, try session user (for regular booking scenarios)
if (!currentSessionUser && ctx) {
try {
// Try to get session user from context using the existing session middleware approach
const { getSession, getUserFromSession } = await import("../../../middlewares/sessionMiddleware");

const session = await getSession(ctx as any);
if (session?.user) {
const userFromSession = await getUserFromSession(ctx as any, session);
if (userFromSession?.id && userFromSession?.email) {
currentSessionUser = {
id: userFromSession.id,
email: userFromSession.email,
};
log.debug("Found session user for intersection", {
userId: userFromSession.id,
email: userFromSession.email,
});
}
}
} catch (error) {
// Session extraction failed, continue without session user (allows public bookings)
log.debug("Failed to extract session user, continuing without session intersection", { error });
}
}

if (process.env.INTEGRATION_TEST_MODE === "true") {
logger.settings.minLevel = 2;
}
Expand Down Expand Up @@ -1209,6 +1257,83 @@ export class AvailableSlotsService {

let aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType);

// If there's a current session user, intersect their availability with the organizer's availability
if (currentSessionUser?.id) {
try {
loggerWithEventDetails.info("Current user detected, intersecting availabilities", {
sessionUserId: currentSessionUser.id,
organizerUserIds: allUsersAvailability.map((u) => u.user.id),
});

// Check if the session user is NOT one of the organizers/hosts
const isSessionUserAnOrganizer = allUsersAvailability.some(
(u) => u.user.id === currentSessionUser.id
);

if (!isSessionUserAnOrganizer) {
// Fetch current session user's availability for the same time period
const sessionUserAvailability = await this.dependencies.userAvailabilityService.getUserAvailability(
{
userId: currentSessionUser.id,
dateFrom: startTime.format(),
dateTo: endTime.format(),
eventTypeId: eventType.id,
returnDateOverrides: false,
bypassBusyCalendarTimes,
silentlyHandleCalendarFailures: silentCalendarFailures,
shouldServeCache,
}
);

loggerWithEventDetails.info("Session user availability fetched", {
sessionUserId: currentSessionUser.id,
hasAvailability: !!sessionUserAvailability.dateRanges?.length,
dateRangesCount: sessionUserAvailability.dateRanges?.length || 0,
});

// If session user has availability, intersect it with the organizer's availability
if (sessionUserAvailability.dateRanges && sessionUserAvailability.dateRanges.length > 0) {
const { intersect } = await import("@calcom/features/schedules/lib/date-ranges");

// Intersect session user's availability with each organizer's availability
const intersectedAvailabilities = allUsersAvailability.map((organizerAvailability) => ({
...organizerAvailability,
dateRanges: intersect([organizerAvailability.dateRanges, sessionUserAvailability.dateRanges]),
oooExcludedDateRanges: intersect([
organizerAvailability.oooExcludedDateRanges,
sessionUserAvailability.dateRanges,
]),
}));

loggerWithEventDetails.info("Availability intersection completed", {
originalOrganizerRanges: allUsersAvailability.map((u) => u.dateRanges?.length || 0),
sessionUserRanges: sessionUserAvailability.dateRanges?.length || 0,
intersectedRanges: intersectedAvailabilities.map((u) => u.dateRanges?.length || 0),
});

// Update the aggregated availability with intersected results
allUsersAvailability = intersectedAvailabilities;
aggregatedAvailability = getAggregatedAvailability(
allUsersAvailability,
eventType.schedulingType
);
} else {
// If session user has no availability, no slots should be available
loggerWithEventDetails.info("Session user has no availability, returning empty slots");
aggregatedAvailability = [];
}
} else {
loggerWithEventDetails.info("Session user is an organizer, no intersection needed");
}
} catch (error) {
// If there's an error fetching session user availability, log it but don't block the booking
loggerWithEventDetails.error("Error fetching session user availability", {
error: error instanceof Error ? error.message : String(error),
sessionUserId: currentSessionUser.id,
});
}
}

// Fairness and Contact Owner have fallbacks because we check for within 2 weeks
if (hasFallbackRRHosts) {
let diff = 0;
Expand Down
Loading