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 = () => { diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 775043eaa95a01..5415047490698d 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,58 @@ 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, + }); + + // 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; + }); + + 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, + }); + } + } + } + // Fairness and Contact Owner have fallbacks because we check for within 2 weeks if (hasFallbackRRHosts) { let diff = 0;