diff --git a/apps/web/modules/schedules/hooks/useEvent.ts b/apps/web/modules/schedules/hooks/useEvent.ts index 46b25d527b8c20..1db84ec2dd3889 100644 --- a/apps/web/modules/schedules/hooks/useEvent.ts +++ b/apps/web/modules/schedules/hooks/useEvent.ts @@ -1,12 +1,10 @@ -import { shallow } from "zustand/shallow"; - import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider"; -import { useSchedule } from "@calcom/web/modules/schedules/hooks/useSchedule"; -import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; -import { trpc } from "@calcom/trpc/react"; - import { useBookerTime } from "@calcom/features/bookings/Booker/hooks/useBookerTime"; import { useStableTimezone } from "@calcom/features/bookings/Booker/hooks/useStableTimezone"; +import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; +import { trpc } from "@calcom/trpc/react"; +import { useSchedule } from "@calcom/web/modules/schedules/hooks/useSchedule"; +import { shallow } from "zustand/shallow"; export type useEventReturnType = ReturnType; export type useScheduleForEventReturnType = ReturnType; @@ -106,6 +104,7 @@ export const useScheduleForEvent = ({ const searchParams = useCompatSearchParams(); const rescheduleUid = searchParams?.get("rescheduleUid"); + const rescheduledBy = searchParams?.get("rescheduledBy"); const schedule = useSchedule({ username: usernameFromStore ?? username, @@ -115,6 +114,7 @@ export const useScheduleForEvent = ({ selectedDate, dayCount, rescheduleUid, + rescheduledBy, month: monthFromStore ?? month, duration: durationFromStore ?? duration, isTeamEvent, diff --git a/apps/web/modules/schedules/hooks/useSchedule.ts b/apps/web/modules/schedules/hooks/useSchedule.ts index 53f01ba8417469..9ac9e51f73d5e7 100644 --- a/apps/web/modules/schedules/hooks/useSchedule.ts +++ b/apps/web/modules/schedules/hooks/useSchedule.ts @@ -1,5 +1,3 @@ -import { useSearchParams } from "next/navigation"; - import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe"; import { sdkActionManager } from "@calcom/embed-core/src/sdk-event"; import { useBookerStore } from "@calcom/features/bookings/Booker/store"; @@ -9,7 +7,7 @@ import { useTimesForSchedule } from "@calcom/features/schedules/hooks/useTimesFo import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams"; import { PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS } from "@calcom/lib/constants"; import { trpc } from "@calcom/trpc/react"; - +import { useSearchParams } from "next/navigation"; import { useApiV2AvailableSlots } from "./useApiV2AvailableSlots"; export type UseScheduleWithCacheArgs = { @@ -22,6 +20,7 @@ export type UseScheduleWithCacheArgs = { duration?: number | null; dayCount?: number | null; rescheduleUid?: string | null; + rescheduledBy?: string | null; isTeamEvent?: boolean; orgSlug?: string; teamMemberEmail?: string | null; @@ -60,6 +59,7 @@ export const useSchedule = ({ duration, dayCount, rescheduleUid, + rescheduledBy, isTeamEvent, orgSlug, teamMemberEmail, @@ -108,6 +108,7 @@ export const useSchedule = ({ timeZone: timezone ?? "PLACEHOLDER_TIMEZONE", duration: duration ? `${duration}` : undefined, rescheduleUid, + rescheduledBy, orgSlug, teamMemberEmail, routedTeamMemberIds, diff --git a/packages/features/availability/lib/getUserAvailability.ts b/packages/features/availability/lib/getUserAvailability.ts index eb94e638d0bc9d..83cc2cb775362d 100644 --- a/packages/features/availability/lib/getUserAvailability.ts +++ b/packages/features/availability/lib/getUserAvailability.ts @@ -160,6 +160,7 @@ export type GetUserAvailabilityInitialData = { bookingLimits?: unknown; includeManagedEventsInLimits: boolean; } | null; + guestBusyTimes?: { start: Date; end: Date }[]; }; export type GetAvailabilityUser = GetUserAvailabilityInitialData["user"]; @@ -617,6 +618,13 @@ export class UserAvailabilityService { }; } + const guestBusyTimesFormatted: EventBusyDetails[] = (initialData?.guestBusyTimes ?? []).map((t) => ({ + start: dayjs.utc(t.start).toISOString(), + end: dayjs.utc(t.end).toISOString(), + title: "Guest busy", + source: withSource ? "guest-availability" : "", + })); + const detailedBusyTimesWithSource: EventBusyDetails[] = [ ...busyTimes.map((a) => ({ ...a, @@ -627,6 +635,7 @@ export class UserAvailabilityService { })), ...busyTimesFromLimits, ...busyTimesFromTeamLimits, + ...guestBusyTimesFormatted, ]; const detailedBusyTimes: UserAvailabilityBusyDetails[] = withSource diff --git a/packages/features/bookings/repositories/BookingRepository.test.ts b/packages/features/bookings/repositories/BookingRepository.test.ts index 965673ad009001..2ea7e582301ffb 100644 --- a/packages/features/bookings/repositories/BookingRepository.test.ts +++ b/packages/features/bookings/repositories/BookingRepository.test.ts @@ -1,4 +1,5 @@ import type { PrismaClient } from "@calcom/prisma"; +import { BookingStatus } from "@calcom/prisma/enums"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { BookingRepository } from "./BookingRepository"; @@ -6,6 +7,10 @@ describe("BookingRepository", () => { let repository: BookingRepository; let mockPrismaClient: { $queryRaw: ReturnType; + booking: { + findUnique: ReturnType; + findMany: ReturnType; + }; }; beforeEach(() => { @@ -13,6 +18,10 @@ describe("BookingRepository", () => { mockPrismaClient = { $queryRaw: vi.fn(), + booking: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, }; repository = new BookingRepository(mockPrismaClient as unknown as PrismaClient); @@ -58,4 +67,177 @@ describe("BookingRepository", () => { expect(mockPrismaClient.$queryRaw).toHaveBeenCalledTimes(1); }); }); + + describe("findByUidIncludeAttendeeEmails", () => { + it("should query booking by uid with attendee emails and host user email", async () => { + const mockBooking = { + id: 1, + uid: "test-uid", + attendees: [{ email: "guest@example.com" }], + user: { email: "host@example.com" }, + }; + mockPrismaClient.booking.findUnique.mockResolvedValue(mockBooking); + + const result = await repository.findByUidIncludeAttendeeEmails({ uid: "test-uid" }); + + expect(result).toEqual(mockBooking); + expect(mockPrismaClient.booking.findUnique).toHaveBeenCalledWith({ + where: { uid: "test-uid" }, + select: { + id: true, + uid: true, + attendees: { select: { email: true } }, + user: { select: { email: true } }, + }, + }); + }); + + it("should return null when booking does not exist", async () => { + mockPrismaClient.booking.findUnique.mockResolvedValue(null); + + const result = await repository.findByUidIncludeAttendeeEmails({ uid: "nonexistent" }); + + expect(result).toBeNull(); + }); + }); + + describe("findByUserIdsAndDateRange", () => { + const dateFrom = new Date("2026-04-01T00:00:00Z"); + const dateTo = new Date("2026-04-30T23:59:59Z"); + + it("should return empty array when both userIds and userEmails are empty", async () => { + const result = await repository.findByUserIdsAndDateRange({ + userIds: [], + userEmails: [], + dateFrom, + dateTo, + }); + + expect(result).toEqual([]); + expect(mockPrismaClient.booking.findMany).not.toHaveBeenCalled(); + }); + + it("should query bookings by userId when userIds are provided", async () => { + const mockBookings = [ + { + uid: "booking-1", + startTime: new Date("2026-04-10T09:00:00Z"), + endTime: new Date("2026-04-10T10:00:00Z"), + title: "Meeting", + userId: 10, + status: BookingStatus.ACCEPTED, + }, + ]; + mockPrismaClient.booking.findMany.mockResolvedValue(mockBookings); + + const result = await repository.findByUserIdsAndDateRange({ + userIds: [10], + userEmails: [], + dateFrom, + dateTo, + }); + + expect(result).toEqual(mockBookings); + expect(mockPrismaClient.booking.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: { in: [BookingStatus.ACCEPTED, BookingStatus.PENDING] }, + AND: [{ startTime: { lt: dateTo } }, { endTime: { gt: dateFrom } }], + }), + }) + ); + }); + + it("should query bookings by email when userEmails are provided", async () => { + mockPrismaClient.booking.findMany.mockResolvedValue([]); + + await repository.findByUserIdsAndDateRange({ + userIds: [], + userEmails: ["guest@example.com"], + dateFrom, + dateTo, + }); + + expect(mockPrismaClient.booking.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: expect.arrayContaining([ + { + attendees: { + some: { email: { in: ["guest@example.com"], mode: "insensitive" } }, + }, + }, + ]), + }), + }) + ); + }); + + it("should combine userId and email conditions in OR clause", async () => { + mockPrismaClient.booking.findMany.mockResolvedValue([]); + + await repository.findByUserIdsAndDateRange({ + userIds: [10, 20], + userEmails: ["guest@example.com"], + dateFrom, + dateTo, + }); + + const callArgs = mockPrismaClient.booking.findMany.mock.calls[0][0]; + expect(callArgs.where.OR).toHaveLength(2); + expect(callArgs.where.OR).toEqual( + expect.arrayContaining([ + { userId: { in: [10, 20] } }, + { + attendees: { + some: { email: { in: ["guest@example.com"], mode: "insensitive" } }, + }, + }, + ]) + ); + }); + + it("should select the correct fields", async () => { + mockPrismaClient.booking.findMany.mockResolvedValue([]); + + await repository.findByUserIdsAndDateRange({ + userIds: [10], + userEmails: [], + dateFrom, + dateTo, + }); + + expect(mockPrismaClient.booking.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: { + uid: true, + startTime: true, + endTime: true, + title: true, + userId: true, + status: true, + }, + }) + ); + }); + + it("should include excludeUid in query when provided", async () => { + const repo = new BookingRepository(mockPrismaClient as unknown as PrismaClient); + await repo.findByUserIdsAndDateRange({ + userIds: [1], + userEmails: [], + dateFrom, + dateTo, + excludeUid: "booking-to-exclude", + }); + + expect(mockPrismaClient.booking.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + uid: { not: "booking-to-exclude" }, + }), + }) + ); + }); + }); }); diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index 908dd4e542bfd2..78fbd28d4ae0ad 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -2213,4 +2213,54 @@ export class BookingRepository implements IBookingRepository { }, }); } + + async findByUidIncludeAttendeeEmails({ uid }: { uid: string }) { + return this.prismaClient.booking.findUnique({ + where: { uid }, + select: { + id: true, + uid: true, + attendees: { select: { email: true } }, + user: { select: { email: true } }, + }, + }); + } + + async findByUserIdsAndDateRange({ + userIds, + userEmails, + dateFrom, + dateTo, + excludeUid, + }: { + userIds: number[]; + userEmails: string[]; + dateFrom: Date; + dateTo: Date; + excludeUid?: string; + }) { + if (!userIds.length && !userEmails.length) return []; + + return this.prismaClient.booking.findMany({ + where: { + status: { in: [BookingStatus.ACCEPTED, BookingStatus.PENDING] }, + AND: [{ startTime: { lt: dateTo } }, { endTime: { gt: dateFrom } }], + OR: [ + ...(userIds.length > 0 ? [{ userId: { in: userIds } }] : []), + ...(userEmails.length > 0 + ? [{ attendees: { some: { email: { in: userEmails, mode: "insensitive" as const } } } }] + : []), + ], + ...(excludeUid ? { uid: { not: excludeUid } } : {}), + }, + select: { + uid: true, + startTime: true, + endTime: true, + title: true, + userId: true, + status: true, + }, + }); + } } diff --git a/packages/features/users/repositories/UserRepository.test.ts b/packages/features/users/repositories/UserRepository.test.ts index 44e7001c96834d..502bc8e875fc5b 100644 --- a/packages/features/users/repositories/UserRepository.test.ts +++ b/packages/features/users/repositories/UserRepository.test.ts @@ -1,7 +1,9 @@ import prismock from "@calcom/testing/lib/__mocks__/prisma"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; +import type { PrismaClient } from "@calcom/prisma"; import { CreationSource } from "@calcom/prisma/enums"; import { beforeEach, describe, expect, test, vi } from "vitest"; + vi.mock("@calcom/app-store/delegationCredential", () => ({ enrichHostsWithDelegationCredentials: vi.fn(), getUsersCredentialsIncludeServiceAccountKey: vi.fn(), @@ -111,4 +113,104 @@ describe("UserRepository", () => { ); }); }); + + describe("findByEmails", () => { + let mockPrismaClient: { + user: { + findMany: ReturnType; + }; + }; + let repo: UserRepository; + + beforeEach(() => { + mockPrismaClient = { + user: { + findMany: vi.fn(), + }, + }; + repo = new UserRepository(mockPrismaClient as unknown as PrismaClient); + }); + + test("should return empty array when emails list is empty", async () => { + const result = await repo.findByEmails({ emails: [] }); + + expect(result).toEqual([]); + expect(mockPrismaClient.user.findMany).not.toHaveBeenCalled(); + }); + + test("should look up users by primary email", async () => { + mockPrismaClient.user.findMany + .mockResolvedValueOnce([{ id: 1, email: "user@example.com" }]) + .mockResolvedValueOnce([]); + + const result = await repo.findByEmails({ emails: ["user@example.com"] }); + + expect(result).toEqual([{ id: 1, email: "user@example.com" }]); + expect(mockPrismaClient.user.findMany).toHaveBeenCalledTimes(2); + }); + + test("should look up users by secondary (verified) email", async () => { + mockPrismaClient.user.findMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 2, email: "primary@example.com" }]); + + const result = await repo.findByEmails({ emails: ["secondary@example.com"] }); + + expect(result).toEqual([{ id: 2, email: "primary@example.com" }]); + expect(mockPrismaClient.user.findMany).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + where: { + secondaryEmails: { + some: { + email: { in: ["secondary@example.com"], mode: "insensitive" }, + emailVerified: { not: null }, + }, + }, + }, + }) + ); + }); + + test("should deduplicate users found via both primary and secondary email", async () => { + mockPrismaClient.user.findMany + .mockResolvedValueOnce([{ id: 1, email: "user@example.com" }]) + .mockResolvedValueOnce([{ id: 1, email: "user@example.com" }]); + + const result = await repo.findByEmails({ emails: ["user@example.com", "alias@example.com"] }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + }); + + test("should normalize emails to lowercase and deduplicate input", async () => { + mockPrismaClient.user.findMany + .mockResolvedValueOnce([{ id: 1, email: "user@example.com" }]) + .mockResolvedValueOnce([]); + + await repo.findByEmails({ emails: ["User@Example.COM", "user@example.com"] }); + + expect(mockPrismaClient.user.findMany).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: { email: { in: ["user@example.com"], mode: "insensitive" } }, + }) + ); + }); + + test("should return multiple distinct users", async () => { + mockPrismaClient.user.findMany + .mockResolvedValueOnce([ + { id: 1, email: "user1@example.com" }, + { id: 2, email: "user2@example.com" }, + ]) + .mockResolvedValueOnce([]); + + const result = await repo.findByEmails({ + emails: ["user1@example.com", "user2@example.com"], + }); + + expect(result).toHaveLength(2); + }); + }); }); diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index b69b99d5500534..ac5d4f4b11c2d4 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -1515,4 +1515,34 @@ export class UserRepository { return { email: user.email, username: user.username }; } + + async findByEmails({ emails }: { emails: string[] }) { + if (!emails.length) return []; + + const normalized = Array.from(new Set(emails.map((e) => e.toLowerCase()))); + + const [byPrimary, bySecondary] = await Promise.all([ + this.prismaClient.user.findMany({ + where: { email: { in: normalized, mode: "insensitive" } }, + select: { id: true, email: true }, + }), + this.prismaClient.user.findMany({ + where: { + secondaryEmails: { + some: { + email: { in: normalized, mode: "insensitive" }, + emailVerified: { not: null }, + }, + }, + }, + select: { id: true, email: true }, + }), + ]); + + const seen = new Map(); + for (const u of [...byPrimary, ...bySecondary]) { + if (!seen.has(u.id)) seen.set(u.id, u); + } + return Array.from(seen.values()); + } } diff --git a/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts new file mode 100644 index 00000000000000..71b5d9f8f1b150 --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts @@ -0,0 +1,491 @@ +import { SchedulingType } from "@calcom/prisma/enums"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { IAvailableSlotsService } from "./util"; +import { AvailableSlotsService } from "./util"; + +describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { + type GetGuestBusyTimesForReschedule = + typeof AvailableSlotsService.prototype._getGuestBusyTimesForReschedule; + let service: AvailableSlotsService; + let mockDependencies: { + bookingRepo: { + findByUidIncludeAttendeeEmails: ReturnType; + findByUserIdsAndDateRange: ReturnType; + }; + userRepo: { + findByEmails: ReturnType; + }; + }; + + const dateFrom = new Date("2026-04-01T00:00:00Z"); + const dateTo = new Date("2026-04-30T23:59:59Z"); + const rescheduleUid = "booking-uid-123"; + const hostEmail = "host@cal.com"; + + beforeEach(() => { + vi.clearAllMocks(); + + mockDependencies = { + bookingRepo: { + findByUidIncludeAttendeeEmails: vi.fn(), + findByUserIdsAndDateRange: vi.fn(), + }, + userRepo: { + findByEmails: vi.fn(), + }, + }; + + service = new AvailableSlotsService(mockDependencies as unknown as IAvailableSlotsService); + }); + + const callGetGuestBusyTimes = (params: { + rescheduleUid: string | null | undefined; + rescheduledBy?: string | null | undefined; + schedulingType: SchedulingType | null; + dateFrom: Date; + dateTo: Date; + }) => + ( + service as unknown as { + _getGuestBusyTimesForReschedule: GetGuestBusyTimesForReschedule; + } + )._getGuestBusyTimesForReschedule(params); + + describe("early-exit conditions", () => { + it("should return empty array when rescheduleUid is null", async () => { + const result = await callGetGuestBusyTimes({ + rescheduleUid: null, + schedulingType: null, + dateFrom, + dateTo, + }); + + expect(result).toEqual([]); + expect(mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails).not.toHaveBeenCalled(); + }); + + it("should return empty array when rescheduleUid is undefined", async () => { + const result = await callGetGuestBusyTimes({ + rescheduleUid: undefined, + schedulingType: null, + dateFrom, + dateTo, + }); + + expect(result).toEqual([]); + expect(mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails).not.toHaveBeenCalled(); + }); + + it("should return empty array for COLLECTIVE scheduling type", async () => { + const result = await callGetGuestBusyTimes({ + rescheduleUid, + schedulingType: SchedulingType.COLLECTIVE, + dateFrom, + dateTo, + }); + + expect(result).toEqual([]); + expect(mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails).not.toHaveBeenCalled(); + }); + + it("should return empty array when original booking has no attendees", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + attendees: [], + user: { email: hostEmail }, + }); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: hostEmail, + schedulingType: null, + dateFrom, + dateTo, + }); + + expect(result).toEqual([]); + expect(mockDependencies.userRepo.findByEmails).not.toHaveBeenCalled(); + }); + + it("should return empty array when original booking is not found", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue(null); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + schedulingType: null, + dateFrom, + dateTo, + }); + + expect(result).toEqual([]); + expect(mockDependencies.userRepo.findByEmails).not.toHaveBeenCalled(); + }); + + it("should return empty array when no attendees are Cal.com users", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + attendees: [{ email: "external@gmail.com" }], + user: { email: hostEmail }, + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([]); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: hostEmail, + schedulingType: null, + dateFrom, + dateTo, + }); + + expect(result).toEqual([]); + expect(mockDependencies.bookingRepo.findByUserIdsAndDateRange).not.toHaveBeenCalled(); + }); + }); + + describe("host vs attendee reschedule gating", () => { + it("should return empty array when attendee initiates reschedule (P2 fix)", async () => { + const attendeeEmail = "attendee@example.com"; + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + attendees: [{ email: attendeeEmail }], + user: { email: hostEmail }, + }); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: attendeeEmail, + schedulingType: null, + dateFrom, + dateTo, + }); + + expect(result).toEqual([]); + expect(mockDependencies.userRepo.findByEmails).not.toHaveBeenCalled(); + }); + + it("should check guest busy times when host initiates reschedule", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + attendees: [{ email: "guest@cal.com" }], + user: { email: hostEmail }, + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); + mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([ + { + uid: "other-booking-1", + startTime: new Date("2026-04-10T09:00:00Z"), + endTime: new Date("2026-04-10T10:00:00Z"), + title: "Team standup", + userId: 10, + status: "ACCEPTED", + }, + ]); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: hostEmail, + schedulingType: null, + dateFrom, + dateTo, + }); + + expect(result).toEqual([ + { + start: new Date("2026-04-10T09:00:00Z"), + end: new Date("2026-04-10T10:00:00Z"), + }, + ]); + }); + + it("should handle case-insensitive host email comparison", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + attendees: [{ email: "guest@cal.com" }], + user: { email: "Host@Cal.COM" }, + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); + mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); + + await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: "host@cal.com", + schedulingType: null, + dateFrom, + dateTo, + }); + + // Should proceed to check guest availability (host email matches case-insensitively) + expect(mockDependencies.userRepo.findByEmails).toHaveBeenCalled(); + }); + + it("should check guest busy times when rescheduledBy is not provided (backwards compat)", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + attendees: [{ email: "guest@cal.com" }], + user: { email: hostEmail }, + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); + mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); + + await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: undefined, + schedulingType: null, + dateFrom, + dateTo, + }); + + // Without rescheduledBy, should still check guest availability (safe default) + expect(mockDependencies.userRepo.findByEmails).toHaveBeenCalled(); + }); + + it("should check guest busy times when rescheduledBy is null (backwards compat)", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + attendees: [{ email: "guest@cal.com" }], + user: { email: hostEmail }, + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); + mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); + + await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: null, + schedulingType: null, + dateFrom, + dateTo, + }); + + // Without rescheduledBy, should still check guest availability (safe default) + expect(mockDependencies.userRepo.findByEmails).toHaveBeenCalled(); + }); + }); + + describe("guest busy time collection", () => { + it("should return busy times for Cal.com guest users", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + attendees: [{ email: "guest@cal.com" }], + user: { email: hostEmail }, + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); + mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([ + { + uid: "other-booking-1", + startTime: new Date("2026-04-10T09:00:00Z"), + endTime: new Date("2026-04-10T10:00:00Z"), + title: "Team standup", + userId: 10, + status: "ACCEPTED", + }, + ]); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: hostEmail, + schedulingType: null, + dateFrom, + dateTo, + }); + + expect(result).toEqual([ + { + start: new Date("2026-04-10T09:00:00Z"), + end: new Date("2026-04-10T10:00:00Z"), + }, + ]); + }); + + it("should pass excludeUid to the booking query to filter at database level", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + attendees: [{ email: "guest@cal.com" }], + user: { email: hostEmail }, + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); + mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([ + { + uid: "different-booking", + startTime: new Date("2026-04-10T16:00:00Z"), + endTime: new Date("2026-04-10T17:00:00Z"), + title: "Another meeting", + userId: 10, + status: "ACCEPTED", + }, + ]); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: hostEmail, + schedulingType: null, + dateFrom, + dateTo, + }); + + expect(mockDependencies.bookingRepo.findByUserIdsAndDateRange).toHaveBeenCalledWith( + expect.objectContaining({ excludeUid: rescheduleUid }) + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + start: new Date("2026-04-10T16:00:00Z"), + end: new Date("2026-04-10T17:00:00Z"), + }); + }); + + it("should return empty array on error (graceful degradation)", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockRejectedValue( + new Error("Database connection lost") + ); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + schedulingType: null, + dateFrom, + dateTo, + }); + + expect(result).toEqual([]); + }); + + it("should only use Cal.com user emails in booking query, not all attendee emails", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + attendees: [{ email: "caluser@cal.com" }, { email: "external@gmail.com" }], + user: { email: hostEmail }, + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "caluser@cal.com" }]); + mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); + + await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: hostEmail, + schedulingType: null, + dateFrom, + dateTo, + }); + + expect(mockDependencies.bookingRepo.findByUserIdsAndDateRange).toHaveBeenCalledWith( + expect.objectContaining({ + userEmails: ["caluser@cal.com"], + }) + ); + const callArgs = mockDependencies.bookingRepo.findByUserIdsAndDateRange.mock.calls[0][0]; + expect(callArgs.userEmails).not.toContain("external@gmail.com"); + }); + + it("should handle multiple guest attendees who are Cal.com users", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + attendees: [{ email: "guest1@cal.com" }, { email: "guest2@cal.com" }], + user: { email: hostEmail }, + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([ + { id: 10, email: "guest1@cal.com" }, + { id: 20, email: "guest2@cal.com" }, + ]); + mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([ + { + uid: "booking-a", + startTime: new Date("2026-04-10T09:00:00Z"), + endTime: new Date("2026-04-10T10:00:00Z"), + title: "Guest1 meeting", + userId: 10, + status: "ACCEPTED", + }, + { + uid: "booking-b", + startTime: new Date("2026-04-11T14:00:00Z"), + endTime: new Date("2026-04-11T15:00:00Z"), + title: "Guest2 meeting", + userId: 20, + status: "ACCEPTED", + }, + ]); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: hostEmail, + schedulingType: null, + dateFrom, + dateTo, + }); + + expect(result).toHaveLength(2); + expect(mockDependencies.userRepo.findByEmails).toHaveBeenCalledWith({ + emails: ["guest1@cal.com", "guest2@cal.com"], + }); + expect(mockDependencies.bookingRepo.findByUserIdsAndDateRange).toHaveBeenCalledWith({ + userIds: [10, 20], + userEmails: ["guest1@cal.com", "guest2@cal.com"], + dateFrom, + dateTo, + excludeUid: rescheduleUid, + }); + }); + + it("should work with ROUND_ROBIN scheduling type", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + attendees: [{ email: "guest@cal.com" }], + user: { email: hostEmail }, + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); + mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: hostEmail, + schedulingType: SchedulingType.ROUND_ROBIN, + dateFrom, + dateTo, + }); + + expect(result).toEqual([]); + expect(mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails).toHaveBeenCalledWith({ + uid: rescheduleUid, + }); + }); + + it("should pass correct userIds and emails to findByUserIdsAndDateRange", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + attendees: [{ email: "cal-user@example.com" }, { email: "external@gmail.com" }], + user: { email: hostEmail }, + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 42, email: "cal-user@example.com" }]); + mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); + + await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: hostEmail, + schedulingType: null, + dateFrom, + dateTo, + }); + + expect(mockDependencies.userRepo.findByEmails).toHaveBeenCalledWith({ + emails: ["cal-user@example.com", "external@gmail.com"], + }); + expect(mockDependencies.bookingRepo.findByUserIdsAndDateRange).toHaveBeenCalledWith({ + userIds: [42], + userEmails: ["cal-user@example.com"], + dateFrom, + dateTo, + excludeUid: rescheduleUid, + }); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index 01320ea4f4a193..0567b4443284bf 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -1,7 +1,6 @@ import type { IncomingMessage } from "node:http"; -import { z } from "zod"; - import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema"; +import { z } from "zod"; const isValidDateString = (val: string) => !isNaN(Date.parse(val)); @@ -27,6 +26,7 @@ export const getScheduleSchemaObject = z.object({ .optional() .transform((val) => val && parseInt(val)), rescheduleUid: z.string().nullish(), + rescheduledBy: z.string().nullish(), // whether to do team event or user event isTeamEvent: z.boolean().optional().default(false), orgSlug: z.string().nullish(), diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 433672093bbef3..e25e465c030871 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -9,6 +9,7 @@ import type { GetAvailabilityUser, UserAvailabilityService, } from "@calcom/features/availability/lib/getUserAvailability"; +import type { IGetAvailableSlots } from "@calcom/features/bookings/Booker/hooks/useAvailableTimeSlots"; import type { CheckBookingLimitsService } from "@calcom/features/bookings/lib/checkBookingLimits"; import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts"; import type { QualifiedHostsService } from "@calcom/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials"; @@ -16,12 +17,14 @@ import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEvent import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import type { BusyTimesService } from "@calcom/features/busyTimes/services/getBusyTimes"; import type { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes"; +import type { OrgMembershipLookup } from "@calcom/features/di/modules/OrgMembershipLookup"; import type { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents"; import type { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; import type { PrismaOOORepository } from "@calcom/features/ooo/repositories/PrismaOOORepository"; import type { IRedisService } from "@calcom/features/redis/IRedisService"; +import type { RoutingFormResponseRepository } from "@calcom/features/routing-forms/repositories/RoutingFormResponseRepository"; import { buildDateRanges } from "@calcom/features/schedules/lib/date-ranges"; import getSlots from "@calcom/features/schedules/lib/slots"; import type { ScheduleRepository } from "@calcom/features/schedules/repositories/ScheduleRepository"; @@ -48,7 +51,6 @@ import { import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { withReporting } from "@calcom/lib/sentryWrapper"; -import type { RoutingFormResponseRepository } from "@calcom/features/routing-forms/repositories/RoutingFormResponseRepository"; import { PeriodType, SchedulingType } from "@calcom/prisma/enums"; import type { CalendarFetchMode, EventBusyDate, EventBusyDetails } from "@calcom/types/Calendar"; import type { CredentialForCalendarService } from "@calcom/types/Credential"; @@ -57,8 +59,6 @@ import type { Logger } from "tslog"; import { v4 as uuid } from "uuid"; import type { TGetScheduleInputSchema } from "./getSchedule.schema"; import type { GetScheduleOptions } from "./types"; -import type { OrgMembershipLookup } from "@calcom/features/di/modules/OrgMembershipLookup"; -import type { IGetAvailableSlots } from "@calcom/features/bookings/Booker/hooks/useAvailableTimeSlots"; const log = logger.getSubLogger({ prefix: ["[slots/util]"] }); const DEFAULT_SLOTS_CACHE_TTL = 2000; @@ -829,6 +829,79 @@ export class AvailableSlotsService { } private getOOODates = withReporting(this._getOOODates.bind(this), "getOOODates"); + /** + * When the host reschedules, check if any attendee is a Cal.com user + * and collect their busy times so the host only sees mutually available slots. + */ + private async _getGuestBusyTimesForReschedule({ + rescheduleUid, + rescheduledBy, + schedulingType, + dateFrom, + dateTo, + }: { + rescheduleUid: string | null | undefined; + rescheduledBy: string | null | undefined; + schedulingType: SchedulingType | null; + dateFrom: Date; + dateTo: Date; + }): Promise<{ start: Date; end: Date }[]> { + if (!rescheduleUid || schedulingType === SchedulingType.COLLECTIVE) { + return []; + } + + try { + const original = await this.dependencies.bookingRepo.findByUidIncludeAttendeeEmails({ + uid: rescheduleUid, + }); + if (!original?.attendees?.length) return []; + + // Only apply guest busy-time blocking for host-initiated reschedules. + // When an attendee reschedules, they should see all available slots + // without being constrained by other guests' schedules. + if (rescheduledBy) { + const hostEmail = original.user?.email; + const isHostReschedule = hostEmail && rescheduledBy.toLowerCase() === hostEmail.toLowerCase(); + if (!isHostReschedule) { + return []; + } + } + + const emails = original.attendees.map((a) => a.email).filter((e): e is string => Boolean(e)); + if (!emails.length) return []; + + const calUsers = await this.dependencies.userRepo.findByEmails({ emails }); + if (!calUsers.length) return []; + + // Only use Cal.com user emails for the booking query, not all attendee emails. + // This prevents pulling in bookings for non-Cal.com guests via the OR email filter. + const calUserEmails = calUsers.map((u) => u.email); + + const guestBookings = await this.dependencies.bookingRepo.findByUserIdsAndDateRange({ + userIds: calUsers.map((u) => u.id), + userEmails: calUserEmails, + dateFrom, + dateTo, + excludeUid: rescheduleUid, + }); + + return guestBookings.map((b) => ({ start: b.startTime, end: b.endTime })); + } catch (error) { + // Graceful degradation: never block rescheduling if guest lookup fails. + // Log at warn (not error) so operators can detect upstream regressions + // without paging on a non-blocking code path. + log.warn( + "[getGuestBusyTimesForReschedule] degraded to empty result", + safeStringify({ rescheduleUid, error }) + ); + return []; + } + } + private getGuestBusyTimesForReschedule = withReporting( + this._getGuestBusyTimesForReschedule.bind(this), + "getGuestBusyTimesForReschedule" + ); + private _getUsersWithCredentials({ hosts, }: { @@ -903,7 +976,7 @@ export class AvailableSlotsService { const allUserIds = Array.from(userIdAndEmailMap.keys()); const bookingRepo = this.dependencies.bookingRepo; - const [currentBookingsAllUsers, outOfOfficeDaysAllUsers] = await Promise.all([ + const [currentBookingsAllUsers, outOfOfficeDaysAllUsers, guestBusyTimes] = await Promise.all([ bookingRepo.findAllExistingBookingsForEventTypeBetween({ startDate: startTimeDate, endDate: endTimeDate, @@ -912,6 +985,13 @@ export class AvailableSlotsService { userIdAndEmailMap, }), this.getOOODates(startTimeDate, endTimeDate, allUserIds), + this.getGuestBusyTimesForReschedule({ + rescheduleUid: input.rescheduleUid, + rescheduledBy: input.rescheduledBy, + schedulingType: eventType.schedulingType, + dateFrom: startTimeDate, + dateTo: endTimeDate, + }), ]); const bookingLimits = @@ -1026,6 +1106,7 @@ export class AvailableSlotsService { eventTypeForLimits: eventType && (bookingLimits || durationLimits) ? eventType : null, teamBookingLimits: teamBookingLimitsMap, teamForBookingLimits: teamForBookingLimits, + guestBusyTimes, }, }); /* We get all users working hours and busy slots */