From 9cfd952468ad77c2378c14185d9aa9e4b7ecfb6a Mon Sep 17 00:00:00 2001 From: "J.A.R.V.I.S." Date: Tue, 3 Feb 2026 19:20:27 +0000 Subject: [PATCH 1/3] feat: check guest's Cal.com availability when host reschedules When a host reschedules a booking, this now checks if the guest (attendee) is a Cal.com user. If they are, the system fetches their availability and only shows time slots when BOTH the host AND the guest are available. This prevents hosts from rescheduling to times when the guest is busy, improving the rescheduling experience for both parties. Changes: - Added _getGuestAvailabilityForReschedule helper method - Integrated availability intersection in _getAvailableSlots - Gracefully handles errors (falls back to host-only availability) Closes #16378 --- .../trpc/server/routers/viewer/slots/util.ts | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 775043eaa95a01..f9e0f0081d3fd3 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -190,6 +190,96 @@ export class AvailableSlotsService { } } + /** + * When rescheduling, check if the guest (attendee) is a Cal.com user. + * If they are, fetch their availability and return time ranges when they're available. + * This allows hosts to only see time slots when both parties are free. + */ + private async _getGuestAvailabilityForReschedule({ + rescheduleUid, + startTime, + endTime, + timeZone, + }: { + rescheduleUid: string; + startTime: Dayjs; + endTime: Dayjs; + timeZone: string | undefined; + }): Promise<{ start: Dayjs; end: Dayjs }[] | null> { + const bookingRepo = this.dependencies.bookingRepo; + const userRepo = this.dependencies.userRepo; + + // Fetch the original booking with attendees + const booking = await bookingRepo.findByUidIncludeEventType({ bookingUid: rescheduleUid }); + if (!booking || !booking.attendees || booking.attendees.length === 0) { + return null; + } + + // Get attendee emails (excluding the host) + const hostEmails = new Set(); + if (booking.user?.email) hostEmails.add(booking.user.email.toLowerCase()); + if (booking.eventType?.hosts) { + booking.eventType.hosts.forEach((h) => { + if (h.user?.email) hostEmails.add(h.user.email.toLowerCase()); + }); + } + if (booking.eventType?.users) { + booking.eventType.users.forEach((u) => { + if (u.email) hostEmails.add(u.email.toLowerCase()); + }); + } + + const guestEmails = booking.attendees + .map((a) => a.email.toLowerCase()) + .filter((email) => !hostEmails.has(email)); + + if (guestEmails.length === 0) { + return null; + } + + // Check if any guest is a Cal.com user + let guestUser = null; + for (const email of guestEmails) { + const user = await userRepo.findByEmail({ email }); + if (user) { + guestUser = user; + break; // Use the first Cal.com user found + } + } + + if (!guestUser) { + return null; // Guest is not a Cal.com user, skip availability check + } + + // Fetch the guest's availability using the user availability service + try { + const guestAvailability = await this.dependencies.userAvailabilityService.getUserAvailability({ + userId: guestUser.id, + dateFrom: startTime, + dateTo: endTime, + returnDateOverrides: false, + }); + + if (!guestAvailability || !guestAvailability.dateRanges) { + return null; + } + + // Return the guest's available date ranges + return guestAvailability.dateRanges.map((range) => ({ + start: dayjs(range.start), + end: dayjs(range.end), + })); + } catch (error) { + log.warn("Failed to fetch guest availability for reschedule", { error, guestUserId: guestUser.id }); + return null; // On error, don't block - just skip guest availability check + } + } + + private getGuestAvailabilityForReschedule = withReporting( + this._getGuestAvailabilityForReschedule.bind(this), + "getGuestAvailabilityForReschedule" + ); + private async _getDynamicEventType( input: TGetScheduleInputSchema, organizationDetails: { currentOrgDomain: string | null; isValidOrgDomain: boolean } @@ -1209,6 +1299,46 @@ export class AvailableSlotsService { let aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType); + // When rescheduling, check if the guest is a Cal.com user and intersect their availability + if (input.rescheduleUid) { + const guestAvailability = await this.getGuestAvailabilityForReschedule({ + rescheduleUid: input.rescheduleUid, + startTime, + endTime, + timeZone: input.timeZone, + }); + + if (guestAvailability && guestAvailability.length > 0) { + // Intersect host availability with guest availability + // Only keep time ranges where both host and guest are available + aggregatedAvailability = aggregatedAvailability.flatMap((hostRange) => { + const intersections: typeof aggregatedAvailability = []; + + for (const guestRange of guestAvailability) { + // Find overlap between host and guest ranges + const overlapStart = hostRange.start.isAfter(guestRange.start) ? hostRange.start : guestRange.start; + const overlapEnd = hostRange.end.isBefore(guestRange.end) ? hostRange.end : guestRange.end; + + // If there's a valid overlap, add it + if (overlapStart.isBefore(overlapEnd)) { + intersections.push({ + start: overlapStart, + end: overlapEnd, + }); + } + } + + return intersections; + }); + + loggerWithEventDetails.info("Intersected host availability with guest Cal.com user availability", { + rescheduleUid: input.rescheduleUid, + originalRanges: allUsersAvailability.length, + intersectedRanges: aggregatedAvailability.length, + }); + } + } + // Fairness and Contact Owner have fallbacks because we check for within 2 weeks if (hasFallbackRRHosts) { let diff = 0; From 448be0f7e6eea3c9bd584efe8d59c59bd2bdc51e Mon Sep 17 00:00:00 2001 From: "J.A.R.V.I.S." Date: Tue, 3 Feb 2026 19:47:48 +0000 Subject: [PATCH 2/3] fix: handle empty guest availability correctly when rescheduling Previously, if a Cal.com user (guest) had NO available slots, the code treated this the same as 'not a Cal.com user' and showed host-only availability. This was incorrect - if the guest is a Cal.com user with zero availability, the result should be no available slots. Fixed by distinguishing between: - null: Guest is not a Cal.com user (show host-only availability) - []: Guest IS a Cal.com user with no availability (return no slots) - [...]: Guest has availability (intersect with host) This addresses the review feedback about empty guest availability. --- .../trpc/server/routers/viewer/slots/util.ts | 62 +++++++++++-------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index f9e0f0081d3fd3..5415047490698d 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -1308,34 +1308,46 @@ export class AvailableSlotsService { timeZone: input.timeZone, }); - if (guestAvailability && guestAvailability.length > 0) { - // Intersect host availability with guest availability - // Only keep time ranges where both host and guest are available - aggregatedAvailability = aggregatedAvailability.flatMap((hostRange) => { - const intersections: typeof aggregatedAvailability = []; - - for (const guestRange of guestAvailability) { - // Find overlap between host and guest ranges - const overlapStart = hostRange.start.isAfter(guestRange.start) ? hostRange.start : guestRange.start; - const overlapEnd = hostRange.end.isBefore(guestRange.end) ? hostRange.end : guestRange.end; - - // If there's a valid overlap, add it - if (overlapStart.isBefore(overlapEnd)) { - intersections.push({ - start: overlapStart, - end: overlapEnd, - }); + // guestAvailability is: + // - null: Guest is not a Cal.com user, show host-only availability + // - []: Guest IS a Cal.com user with NO availability, return no slots + // - [...]: Guest has availability, intersect with host + if (guestAvailability !== null) { + if (guestAvailability.length > 0) { + // Intersect host availability with guest availability + // Only keep time ranges where both host and guest are available + aggregatedAvailability = aggregatedAvailability.flatMap((hostRange) => { + const intersections: typeof aggregatedAvailability = []; + + for (const guestRange of guestAvailability) { + // Find overlap between host and guest ranges + const overlapStart = hostRange.start.isAfter(guestRange.start) ? hostRange.start : guestRange.start; + const overlapEnd = hostRange.end.isBefore(guestRange.end) ? hostRange.end : guestRange.end; + + // If there's a valid overlap, add it + if (overlapStart.isBefore(overlapEnd)) { + intersections.push({ + start: overlapStart, + end: overlapEnd, + }); + } } - } - return intersections; - }); + return intersections; + }); - loggerWithEventDetails.info("Intersected host availability with guest Cal.com user availability", { - rescheduleUid: input.rescheduleUid, - originalRanges: allUsersAvailability.length, - intersectedRanges: aggregatedAvailability.length, - }); + loggerWithEventDetails.info("Intersected host availability with guest Cal.com user availability", { + rescheduleUid: input.rescheduleUid, + originalRanges: allUsersAvailability.length, + intersectedRanges: aggregatedAvailability.length, + }); + } else { + // Guest is a Cal.com user with NO availability - no reschedule slots available + aggregatedAvailability = []; + loggerWithEventDetails.info("Guest Cal.com user has no availability - no reschedule slots available", { + rescheduleUid: input.rescheduleUid, + }); + } } } From 5b47fa176be0201d9381ebc6243d4ee865d8840f Mon Sep 17 00:00:00 2001 From: "J.A.R.V.I.S." Date: Tue, 3 Feb 2026 19:56:26 +0000 Subject: [PATCH 3/3] test: add unit tests for guest availability on reschedule Tests cover: - Returns null when booking not found - Returns null when booking has no attendees - Returns null when guest is not a Cal.com user - Returns guest availability when guest IS a Cal.com user with slots - Returns empty array when guest IS a Cal.com user with NO availability - Excludes host emails from guest list - Gracefully returns null on error (doesn't throw) These tests verify the fix for the empty guest availability bug, ensuring we distinguish between: - null: Guest is not a Cal.com user - []: Guest IS a Cal.com user with no availability - [...]: Guest has availability --- .../server/routers/viewer/slots/util.test.ts | 247 +++++++++++++++++- 1 file changed, 246 insertions(+), 1 deletion(-) diff --git a/packages/trpc/server/routers/viewer/slots/util.test.ts b/packages/trpc/server/routers/viewer/slots/util.test.ts index 1112a02f51bd2a..941573aa49866b 100644 --- a/packages/trpc/server/routers/viewer/slots/util.test.ts +++ b/packages/trpc/server/routers/viewer/slots/util.test.ts @@ -1,9 +1,254 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import dayjs from "@calcom/dayjs"; import { BookingDateInPastError, isTimeOutOfBounds } from "@calcom/lib/isOutOfBounds"; import { TRPCError } from "@trpc/server"; +// Mock dependencies for AvailableSlotsService +const createMockDependencies = (overrides: { + bookingRepo?: { + findByUidIncludeEventType?: ReturnType; + }; + userRepo?: { + findByEmail?: ReturnType; + }; + userAvailabilityService?: { + getUserAvailability?: ReturnType; + }; +} = {}) => ({ + selectedSlotRepo: { findUniqueByUid: vi.fn() }, + userRepo: { + findByEmail: overrides.userRepo?.findByEmail ?? vi.fn().mockResolvedValue(null), + findManyUsersForDynamicEventType: vi.fn(), + findUsersByUsername: vi.fn(), + }, + bookingRepo: { + findByUidIncludeEventType: + overrides.bookingRepo?.findByUidIncludeEventType ?? vi.fn().mockResolvedValue(null), + }, + eventTypeRepo: { + findForSlots: vi.fn(), + findFirstEventTypeId: vi.fn(), + }, + teamRepo: { findById: vi.fn() }, + userAvailabilityService: { + getUserAvailability: + overrides.userAvailabilityService?.getUserAvailability ?? vi.fn().mockResolvedValue(null), + }, + busyTimesService: { getBusyTimes: vi.fn() }, +}); + +describe("Guest Availability for Reschedule", () => { + describe("_getGuestAvailabilityForReschedule", () => { + it("should return null when booking is not found", async () => { + const { AvailableSlotsService } = await import("./util"); + const mockDeps = createMockDependencies({ + bookingRepo: { + findByUidIncludeEventType: vi.fn().mockResolvedValue(null), + }, + }); + + const service = new AvailableSlotsService(mockDeps as any); + const result = await (service as any)._getGuestAvailabilityForReschedule({ + rescheduleUid: "test-uid", + startTime: dayjs("2026-02-10T09:00:00Z"), + endTime: dayjs("2026-02-10T17:00:00Z"), + timeZone: "UTC", + }); + + expect(result).toBeNull(); + }); + + it("should return null when booking has no attendees", async () => { + const { AvailableSlotsService } = await import("./util"); + const mockDeps = createMockDependencies({ + bookingRepo: { + findByUidIncludeEventType: vi.fn().mockResolvedValue({ + user: { id: 1, email: "host@example.com" }, + attendees: [], + eventType: null, + }), + }, + }); + + const service = new AvailableSlotsService(mockDeps as any); + const result = await (service as any)._getGuestAvailabilityForReschedule({ + rescheduleUid: "test-uid", + startTime: dayjs("2026-02-10T09:00:00Z"), + endTime: dayjs("2026-02-10T17:00:00Z"), + timeZone: "UTC", + }); + + expect(result).toBeNull(); + }); + + it("should return null when guest is not a Cal.com user", async () => { + const { AvailableSlotsService } = await import("./util"); + const mockDeps = createMockDependencies({ + bookingRepo: { + findByUidIncludeEventType: vi.fn().mockResolvedValue({ + user: { id: 1, email: "host@example.com" }, + attendees: [{ email: "guest@external.com" }], + eventType: null, + }), + }, + userRepo: { + findByEmail: vi.fn().mockResolvedValue(null), // Guest not found + }, + }); + + const service = new AvailableSlotsService(mockDeps as any); + const result = await (service as any)._getGuestAvailabilityForReschedule({ + rescheduleUid: "test-uid", + startTime: dayjs("2026-02-10T09:00:00Z"), + endTime: dayjs("2026-02-10T17:00:00Z"), + timeZone: "UTC", + }); + + expect(result).toBeNull(); + expect(mockDeps.userRepo.findByEmail).toHaveBeenCalledWith({ email: "guest@external.com" }); + }); + + it("should return guest availability when guest IS a Cal.com user with slots", async () => { + const { AvailableSlotsService } = await import("./util"); + const mockGuestAvailability = { + dateRanges: [ + { start: "2026-02-10T10:00:00Z", end: "2026-02-10T12:00:00Z" }, + { start: "2026-02-10T14:00:00Z", end: "2026-02-10T16:00:00Z" }, + ], + }; + + const mockDeps = createMockDependencies({ + bookingRepo: { + findByUidIncludeEventType: vi.fn().mockResolvedValue({ + user: { id: 1, email: "host@example.com" }, + attendees: [{ email: "guest@cal.com" }], + eventType: null, + }), + }, + userRepo: { + findByEmail: vi.fn().mockResolvedValue({ id: 2, email: "guest@cal.com" }), + }, + userAvailabilityService: { + getUserAvailability: vi.fn().mockResolvedValue(mockGuestAvailability), + }, + }); + + const service = new AvailableSlotsService(mockDeps as any); + const result = await (service as any)._getGuestAvailabilityForReschedule({ + rescheduleUid: "test-uid", + startTime: dayjs("2026-02-10T09:00:00Z"), + endTime: dayjs("2026-02-10T17:00:00Z"), + timeZone: "UTC", + }); + + expect(result).toHaveLength(2); + expect(result[0].start.toISOString()).toBe("2026-02-10T10:00:00.000Z"); + expect(result[0].end.toISOString()).toBe("2026-02-10T12:00:00.000Z"); + }); + + it("should return empty array when guest IS a Cal.com user with NO availability", async () => { + const { AvailableSlotsService } = await import("./util"); + const mockGuestAvailability = { + dateRanges: [], // Guest has no available slots + }; + + const mockDeps = createMockDependencies({ + bookingRepo: { + findByUidIncludeEventType: vi.fn().mockResolvedValue({ + user: { id: 1, email: "host@example.com" }, + attendees: [{ email: "guest@cal.com" }], + eventType: null, + }), + }, + userRepo: { + findByEmail: vi.fn().mockResolvedValue({ id: 2, email: "guest@cal.com" }), + }, + userAvailabilityService: { + getUserAvailability: vi.fn().mockResolvedValue(mockGuestAvailability), + }, + }); + + const service = new AvailableSlotsService(mockDeps as any); + const result = await (service as any)._getGuestAvailabilityForReschedule({ + rescheduleUid: "test-uid", + startTime: dayjs("2026-02-10T09:00:00Z"), + endTime: dayjs("2026-02-10T17:00:00Z"), + timeZone: "UTC", + }); + + // Important: Returns empty array (not null) - guest IS a Cal user but has no availability + expect(result).toEqual([]); + expect(result).not.toBeNull(); + }); + + it("should exclude host emails from guest list", async () => { + const { AvailableSlotsService } = await import("./util"); + const mockDeps = createMockDependencies({ + bookingRepo: { + findByUidIncludeEventType: vi.fn().mockResolvedValue({ + user: { id: 1, email: "host@example.com" }, + attendees: [ + { email: "host@example.com" }, // Host is also in attendees + { email: "guest@external.com" }, + ], + eventType: { + hosts: [{ user: { email: "cohost@example.com" } }], + users: [{ id: 3, email: "teamuser@example.com" }], + }, + }), + }, + userRepo: { + findByEmail: vi.fn().mockResolvedValue(null), + }, + }); + + const service = new AvailableSlotsService(mockDeps as any); + await (service as any)._getGuestAvailabilityForReschedule({ + rescheduleUid: "test-uid", + startTime: dayjs("2026-02-10T09:00:00Z"), + endTime: dayjs("2026-02-10T17:00:00Z"), + timeZone: "UTC", + }); + + // Should only check the actual guest, not the host + expect(mockDeps.userRepo.findByEmail).toHaveBeenCalledTimes(1); + expect(mockDeps.userRepo.findByEmail).toHaveBeenCalledWith({ email: "guest@external.com" }); + }); + + it("should return null on error and not throw", async () => { + const { AvailableSlotsService } = await import("./util"); + const mockDeps = createMockDependencies({ + bookingRepo: { + findByUidIncludeEventType: vi.fn().mockResolvedValue({ + user: { id: 1, email: "host@example.com" }, + attendees: [{ email: "guest@cal.com" }], + eventType: null, + }), + }, + userRepo: { + findByEmail: vi.fn().mockResolvedValue({ id: 2, email: "guest@cal.com" }), + }, + userAvailabilityService: { + getUserAvailability: vi.fn().mockRejectedValue(new Error("Service unavailable")), + }, + }); + + const service = new AvailableSlotsService(mockDeps as any); + const result = await (service as any)._getGuestAvailabilityForReschedule({ + rescheduleUid: "test-uid", + startTime: dayjs("2026-02-10T09:00:00Z"), + endTime: dayjs("2026-02-10T17:00:00Z"), + timeZone: "UTC", + }); + + // Should gracefully return null on error, not throw + expect(result).toBeNull(); + }); + }); +}); + describe("BookingDateInPastError handling", () => { it("should convert BookingDateInPastError to TRPCError with BAD_REQUEST code", () => { const testFilteringLogic = () => {