diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index e336f70d6a0347..ca1292e6fd6a1e 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -931,6 +931,26 @@ export class BookingRepository implements IBookingRepository { }); } + /** + * Fetches attendee emails and the organizer's user ID for a booking. + * Used when rescheduling to determine guest availability. + */ + async findBookingAttendeesByUid({ bookingUid }: { bookingUid: string }) { + return await this.prismaClient.booking.findUnique({ + where: { + uid: bookingUid, + }, + select: { + userId: true, + attendees: { + select: { + email: true, + }, + }, + }, + }); + } + async findFirstBookingFromResponse({ responseId }: { responseId: number }) { const booking = await this.prismaClient.booking.findFirst({ where: { diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index 057169030fff6c..2ac807e2b42a05 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -1456,4 +1456,49 @@ export class UserRepository { return { email: user.email, username: user.username }; } + + /** + * Finds Cal.com users by their email addresses with availability data. + * Used when rescheduling to check guest availability. + * + * Uses raw SQL UNION to check both primary and secondary (verified) emails, + * following the same pattern as findVerifiedUsersByEmailsRaw for performance. + * Emails are stored lowercase so no LOWER() is needed (avoids sequential scan). + */ + async findUsersByEmailsForAvailability({ emails }: { emails: string[] }) { + if (!emails.length) return []; + const normalizedEmails = emails.map((e) => e.toLowerCase()); + + // Step 1: Fast raw SQL lookup for matching user IDs via UNION (primary + secondary emails) + const emailListSql = Prisma.join(normalizedEmails.map((e) => Prisma.sql`${e}`)); + const matchedUsers = await this.prismaClient.$queryRaw>(Prisma.sql` + SELECT u."id" + FROM "public"."users" AS u + WHERE u."email" IN (${emailListSql}) + AND u."emailVerified" IS NOT NULL + AND u."locked" = FALSE + UNION + SELECT u."id" + FROM "public"."users" AS u + INNER JOIN "public"."SecondaryEmail" AS t0 + ON t0."userId" = u."id" + WHERE t0."email" IN (${emailListSql}) + AND t0."emailVerified" IS NOT NULL + AND u."locked" = FALSE + `); + + if (matchedUsers.length === 0) return []; + + // Step 2: Fetch full availability data via Prisma (handles nested relations) + const userIds = matchedUsers.map((u) => u.id); + const users = await this.prismaClient.user.findMany({ + where: { id: { in: userIds } }, + select: { + ...availabilityUserSelect, + credentials: { select: credentialForCalendarServiceSelect }, + }, + }); + return users.map(withSelectedCalendars); + } + } diff --git a/packages/trpc/server/routers/viewer/slots/getGuestAvailabilityForReschedule.test.ts b/packages/trpc/server/routers/viewer/slots/getGuestAvailabilityForReschedule.test.ts new file mode 100644 index 00000000000000..850826959343c0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/getGuestAvailabilityForReschedule.test.ts @@ -0,0 +1,331 @@ +import dayjs from "@calcom/dayjs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { IAvailableSlotsService } from "./util"; +import { AvailableSlotsService } from "./util"; + +describe("AvailableSlotsService - _getGuestAvailabilityForReschedule", () => { + type GetGuestAvailability = typeof AvailableSlotsService.prototype._getGuestAvailabilityForReschedule; + let service: AvailableSlotsService; + let mockDependencies: { + bookingRepo: { + findBookingAttendeesByUid: ReturnType; + }; + userRepo: { + findUsersByEmailsForAvailability: ReturnType; + }; + userAvailabilityService: { + getUsersAvailability: ReturnType; + }; + }; + + const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Parameters[0]["loggerWithEventDetails"]; + + /** Helper to build a mock session ctx for host-initiated reschedules */ + const hostCtx = (email: string) => ({ + session: { user: { id: 1, email } }, + }); + + /** Helper for unauthenticated (guest via email link) reschedules */ + const guestCtx = undefined; + + beforeEach(() => { + vi.clearAllMocks(); + + mockDependencies = { + bookingRepo: { + findBookingAttendeesByUid: vi.fn(), + }, + userRepo: { + findUsersByEmailsForAvailability: vi.fn(), + }, + userAvailabilityService: { + getUsersAvailability: vi.fn(), + }, + }; + + service = new AvailableSlotsService(mockDependencies as unknown as IAvailableSlotsService); + }); + + const callMethod = (overrides: Partial[0]> = {}) => + ( + service as unknown as { _getGuestAvailabilityForReschedule: GetGuestAvailability } + )._getGuestAvailabilityForReschedule({ + rescheduleUid: "booking-123", + ctx: hostCtx("host@cal.com"), // default: host-initiated + startTime: dayjs("2026-03-01"), + endTime: dayjs("2026-03-08"), + duration: 30, + bypassBusyCalendarTimes: false, + silentCalendarFailures: false, + mode: "slots", + loggerWithEventDetails: mockLogger, + ...overrides, + }); + + // ─── Host-vs-Guest Reschedule Distinction ─────────────────────────── + + describe("host-vs-guest reschedule distinction", () => { + it("should skip guest availability check when no session (guest self-reschedule via email link)", async () => { + mockDependencies.bookingRepo.findBookingAttendeesByUid.mockResolvedValue({ + userId: 1, + attendees: [{ email: "guest@cal.com" }], + }); + + const result = await callMethod({ ctx: guestCtx }); + + expect(result).toBeNull(); + // Should not even look up Cal.com users — early return + expect(mockDependencies.userRepo.findUsersByEmailsForAvailability).not.toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining("No authenticated session") + ); + }); + + it("should skip guest availability check when session has no user email", async () => { + mockDependencies.bookingRepo.findBookingAttendeesByUid.mockResolvedValue({ + userId: 1, + attendees: [{ email: "guest@cal.com" }], + }); + + const result = await callMethod({ ctx: { session: { user: { id: 1 } } } }); + + expect(result).toBeNull(); + expect(mockDependencies.userRepo.findUsersByEmailsForAvailability).not.toHaveBeenCalled(); + }); + + it("should skip guest availability check when logged-in user is an attendee (guest self-reschedule while logged in)", async () => { + mockDependencies.bookingRepo.findBookingAttendeesByUid.mockResolvedValue({ + userId: 1, + attendees: [{ email: "guest@cal.com" }], + }); + + // Guest is logged in with their own Cal.com account + const result = await callMethod({ ctx: hostCtx("guest@cal.com") }); + + expect(result).toBeNull(); + expect(mockDependencies.userRepo.findUsersByEmailsForAvailability).not.toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining("guest self-reschedule") + ); + }); + + it("should check guest availability when host is logged in and reschedules", async () => { + const hostUserId = 1; + const guestUser = { + id: 2, + email: "guest@cal.com", + username: "guest", + timeZone: "UTC", + bufferTime: 0, + timeFormat: 24, + defaultScheduleId: 1, + isPlatformManaged: false, + schedules: [], + availability: [], + allSelectedCalendars: [], + userLevelSelectedCalendars: [], + travelSchedules: [], + credentials: [], + }; + + const mockDateRanges = [ + { start: dayjs("2026-03-01T09:00:00Z"), end: dayjs("2026-03-01T17:00:00Z") }, + ]; + + mockDependencies.bookingRepo.findBookingAttendeesByUid.mockResolvedValue({ + userId: hostUserId, + attendees: [{ email: "guest@cal.com" }], + }); + mockDependencies.userRepo.findUsersByEmailsForAvailability.mockResolvedValue([guestUser]); + mockDependencies.userAvailabilityService.getUsersAvailability.mockResolvedValue([ + { dateRanges: mockDateRanges }, + ]); + + const result = await callMethod({ ctx: hostCtx("host@cal.com") }); + + expect(result).toEqual(mockDateRanges); + expect(mockDependencies.userRepo.findUsersByEmailsForAvailability).toHaveBeenCalledWith({ + emails: ["guest@cal.com"], + }); + }); + }); + + // ─── Booking & Attendee Edge Cases ────────────────────────────────── + + describe("when booking has no attendees", () => { + it("should return null if booking not found", async () => { + mockDependencies.bookingRepo.findBookingAttendeesByUid.mockResolvedValue(null); + + const result = await callMethod(); + + expect(result).toBeNull(); + expect(mockDependencies.userRepo.findUsersByEmailsForAvailability).not.toHaveBeenCalled(); + }); + + it("should return null if booking has empty attendees array", async () => { + mockDependencies.bookingRepo.findBookingAttendeesByUid.mockResolvedValue({ + userId: 1, + attendees: [], + }); + + const result = await callMethod(); + + expect(result).toBeNull(); + }); + }); + + describe("when attendee is not a Cal.com user", () => { + it("should return null when no Cal.com users found for attendee emails", async () => { + mockDependencies.bookingRepo.findBookingAttendeesByUid.mockResolvedValue({ + userId: 1, + attendees: [{ email: "external-guest@gmail.com" }], + }); + mockDependencies.userRepo.findUsersByEmailsForAvailability.mockResolvedValue([]); + + const result = await callMethod(); + + expect(result).toBeNull(); + expect(mockDependencies.userRepo.findUsersByEmailsForAvailability).toHaveBeenCalledWith({ + emails: ["external-guest@gmail.com"], + }); + expect(mockLogger.debug).toHaveBeenCalledWith( + "No Cal.com guest users found for reschedule, skipping guest availability check" + ); + }); + }); + + describe("when attendee is the host (same as organizer)", () => { + it("should return null when the only Cal.com user found is the host", async () => { + const hostUserId = 1; + mockDependencies.bookingRepo.findBookingAttendeesByUid.mockResolvedValue({ + userId: hostUserId, + attendees: [{ email: "host@cal.com" }], + }); + // The found user has the same ID as the booking's host + mockDependencies.userRepo.findUsersByEmailsForAvailability.mockResolvedValue([ + { id: hostUserId, email: "host@cal.com", username: "host" }, + ]); + + // Use a team admin email (not the host email, not an attendee email) + const result = await callMethod({ ctx: hostCtx("admin@cal.com") }); + + expect(result).toBeNull(); + }); + }); + + describe("when attendee is a Cal.com user (guest) — host reschedule", () => { + it("should fetch and return guest availability date ranges", async () => { + const hostUserId = 1; + const guestUserId = 2; + const guestUser = { + id: guestUserId, + email: "guest@cal.com", + username: "guest", + timeZone: "UTC", + bufferTime: 0, + timeFormat: 24, + defaultScheduleId: 1, + isPlatformManaged: false, + schedules: [], + availability: [], + allSelectedCalendars: [], + userLevelSelectedCalendars: [], + travelSchedules: [], + credentials: [], + }; + + const mockDateRanges = [ + { start: dayjs("2026-03-01T09:00:00Z"), end: dayjs("2026-03-01T17:00:00Z") }, + { start: dayjs("2026-03-02T09:00:00Z"), end: dayjs("2026-03-02T17:00:00Z") }, + ]; + + mockDependencies.bookingRepo.findBookingAttendeesByUid.mockResolvedValue({ + userId: hostUserId, + attendees: [{ email: "guest@cal.com" }], + }); + mockDependencies.userRepo.findUsersByEmailsForAvailability.mockResolvedValue([guestUser]); + mockDependencies.userAvailabilityService.getUsersAvailability.mockResolvedValue([ + { dateRanges: mockDateRanges }, + ]); + + const result = await callMethod({ ctx: hostCtx("host@cal.com") }); + + expect(result).toEqual(mockDateRanges); + expect(mockDependencies.userAvailabilityService.getUsersAvailability).toHaveBeenCalledWith({ + users: [{ ...guestUser, isFixed: true }], + query: expect.objectContaining({ + dateFrom: expect.any(String), + dateTo: expect.any(String), + duration: 30, + returnDateOverrides: false, + }), + initialData: expect.objectContaining({ + rescheduleUid: "booking-123", + }), + }); + expect(mockLogger.debug).toHaveBeenCalledWith( + "Found Cal.com guest user(s) for reschedule", + expect.objectContaining({ guestEmails: ["guest@cal.com"] }) + ); + }); + + it("should return null when getUsersAvailability returns empty array", async () => { + mockDependencies.bookingRepo.findBookingAttendeesByUid.mockResolvedValue({ + userId: 1, + attendees: [{ email: "guest@cal.com" }], + }); + mockDependencies.userRepo.findUsersByEmailsForAvailability.mockResolvedValue([ + { id: 2, email: "guest@cal.com", username: "guest" }, + ]); + mockDependencies.userAvailabilityService.getUsersAvailability.mockResolvedValue([]); + + const result = await callMethod({ ctx: hostCtx("host@cal.com") }); + + expect(result).toBeNull(); + }); + }); + + describe("with multiple attendees", () => { + it("should use the first non-host Cal.com user found", async () => { + const hostUserId = 1; + const firstGuestUser = { id: 2, email: "first@cal.com", username: "first" }; + const secondGuestUser = { id: 3, email: "second@cal.com", username: "second" }; + + const mockDateRanges = [ + { start: dayjs("2026-03-01T10:00:00Z"), end: dayjs("2026-03-01T12:00:00Z") }, + ]; + + mockDependencies.bookingRepo.findBookingAttendeesByUid.mockResolvedValue({ + userId: hostUserId, + attendees: [ + { email: "first@cal.com" }, + { email: "second@cal.com" }, + { email: "external@gmail.com" }, + ], + }); + // Return both Cal.com users + mockDependencies.userRepo.findUsersByEmailsForAvailability.mockResolvedValue([ + firstGuestUser, + secondGuestUser, + ]); + mockDependencies.userAvailabilityService.getUsersAvailability.mockResolvedValue([ + { dateRanges: mockDateRanges }, + ]); + + const result = await callMethod({ ctx: hostCtx("host@cal.com") }); + + expect(result).toEqual(mockDateRanges); + // Verify it used the first guest user + expect(mockDependencies.userAvailabilityService.getUsersAvailability).toHaveBeenCalledWith( + expect.objectContaining({ + users: [expect.objectContaining({ id: 2, email: "first@cal.com" })], + }) + ); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index 01320ea4f4a193..eae7759c587f6f 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -82,6 +82,12 @@ export const removeSelectedSlotSchema = z.object({ export interface ContextForGetSchedule extends Record { req?: (IncomingMessage & { cookies: Partial<{ [key: string]: string }> }) | undefined; + session?: { + user?: { + id?: number; + email?: string | null; + }; + } | null; } export type TGetScheduleInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 14fba5d1da3beb..6dc38341231337 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -22,7 +22,7 @@ import type { EventTypeRepository } from "@calcom/features/eventtypes/repositori 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 { buildDateRanges } from "@calcom/features/schedules/lib/date-ranges"; +import { buildDateRanges, intersect } from "@calcom/features/schedules/lib/date-ranges"; import getSlots from "@calcom/features/schedules/lib/slots"; import type { ScheduleRepository } from "@calcom/features/schedules/repositories/ScheduleRepository"; import type { ISelectedSlotRepository } from "@calcom/features/selectedSlots/repositories/ISelectedSlotRepository"; @@ -56,7 +56,7 @@ import { TRPCError } from "@trpc/server"; import type { Logger } from "tslog"; import { v4 as uuid } from "uuid"; import type { TGetScheduleInputSchema } from "./getSchedule.schema"; -import type { GetScheduleOptions } from "./types"; +import type { GetScheduleOptions, ContextForGetSchedule } from "./types"; import type { OrgMembershipLookup } from "@calcom/features/di/modules/OrgMembershipLookup"; import type { IGetAvailableSlots } from "@calcom/features/bookings/Booker/hooks/useAvailableTimeSlots"; @@ -990,6 +990,132 @@ export class AvailableSlotsService { }; } + /** + * When rescheduling, fetches the original booking's attendees and checks if any is a Cal.com user. + * If a guest is found as a Cal.com user, returns their available date ranges so that + * only mutually available time slots (host AND guest) are shown. + * + * Guest availability is only checked when the HOST (event owner / team admin) reschedules. + * When a guest reschedules themselves (via email link, not logged in — or logged in as an attendee), + * we skip this check so they can pick any available slot on the host's calendar. + */ + private async _getGuestAvailabilityForReschedule({ + rescheduleUid, + ctx, + startTime, + endTime, + duration, + bypassBusyCalendarTimes, + silentCalendarFailures, + mode, + loggerWithEventDetails, + }: { + rescheduleUid: string; + ctx?: ContextForGetSchedule; + startTime: Dayjs; + endTime: Dayjs; + duration: number; + bypassBusyCalendarTimes: boolean; + silentCalendarFailures: boolean; + mode?: CalendarFetchMode; + loggerWithEventDetails: Logger; + }) { + // 1. Fetch the original booking's attendee emails + const booking = await this.dependencies.bookingRepo.findBookingAttendeesByUid({ + bookingUid: rescheduleUid, + }); + + if (!booking?.attendees?.length) { + return null; + } + + // 2. Determine if this is a host-initiated reschedule. + // Guests reschedule via email link (no session). Only check guest availability + // when the event type owner or team admin reschedules. + const sessionUserEmail = ctx?.session?.user?.email; + if (!sessionUserEmail) { + loggerWithEventDetails.debug( + "No authenticated session — guest self-reschedule, skipping guest availability check" + ); + return null; + } + + const attendeeEmails = booking.attendees.map((a) => a.email); + + // If the logged-in user IS one of the attendees, they're a guest rescheduling themselves + const isGuestSelfReschedule = attendeeEmails.some( + (email) => email.toLowerCase() === sessionUserEmail.toLowerCase() + ); + if (isGuestSelfReschedule) { + loggerWithEventDetails.debug( + "Authenticated user is a booking attendee — guest self-reschedule, skipping guest availability check" + ); + return null; + } + + loggerWithEventDetails.debug("Host-initiated reschedule detected, checking guest availability"); + + // 2. Look up if any attendee is a registered Cal.com user + const guestCalUsers = await this.dependencies.userRepo.findUsersByEmailsForAvailability({ + emails: attendeeEmails, + }); + + // Filter out the host user (the organizer of the booking) + const guests = guestCalUsers.filter((user) => user.id !== booking.userId); + + if (guests.length === 0) { + loggerWithEventDetails.debug( + "No Cal.com guest users found for reschedule, skipping guest availability check" + ); + return null; + } + + loggerWithEventDetails.debug("Found Cal.com guest user(s) for reschedule", { + guestCount: guests.length, + }); + + // 3. Fetch availability for guest Cal.com user (the booker) + // Use the first guest — in most cases there's one main booker + const guestUser = guests[0]; + + const guestAvailabilityResults = await this.dependencies.userAvailabilityService.getUsersAvailability({ + users: [ + { + ...guestUser, + isFixed: true, + }, + ], + query: { + dateFrom: startTime.format(), + dateTo: endTime.format(), + // Don't pass eventTypeId — guest's availability shouldn't be constrained by host's event type + afterEventBuffer: 0, + beforeEventBuffer: 0, + duration, + returnDateOverrides: false, + bypassBusyCalendarTimes, + silentlyHandleCalendarFailures: silentCalendarFailures, + mode, + }, + initialData: { + // No event type for guest — we want their general availability + rescheduleUid, + }, + }); + + if (guestAvailabilityResults.length === 0) { + return null; + } + + // Return the guest's available date ranges (working hours minus busy times) + return guestAvailabilityResults[0].dateRanges; + } + + private getGuestAvailabilityForReschedule = withReporting( + this._getGuestAvailabilityForReschedule.bind(this), + "getGuestAvailabilityForReschedule" + ); + private async checkRestrictionScheduleEnabled(teamId?: number): Promise { if (!teamId) { return false; @@ -1275,6 +1401,45 @@ export class AvailableSlotsService { } } + // When rescheduling, check if any attendee (guest/booker) is a Cal.com user. + // If so, intersect their availability with the host's to only show mutually available slots. + // Only applies for host-initiated reschedules (not guest self-reschedule). + if (input.rescheduleUid) { + try { + const guestDateRanges = await this.getGuestAvailabilityForReschedule({ + rescheduleUid: input.rescheduleUid, + ctx, + startTime, + endTime, + duration: input.duration || 0, + bypassBusyCalendarTimes, + silentCalendarFailures, + mode, + loggerWithEventDetails, + }); + + if (guestDateRanges && guestDateRanges.length > 0) { + aggregatedAvailability = intersect([aggregatedAvailability, guestDateRanges]); + loggerWithEventDetails.info( + "Intersected host availability with guest availability for reschedule", + { + guestDateRangesCount: guestDateRanges.length, + resultingAvailabilityCount: aggregatedAvailability.length, + } + ); + } + } catch (error) { + // Non-fatal: if we can't get guest availability, fall back to host-only availability + loggerWithEventDetails.warn( + "Failed to fetch guest availability for reschedule, continuing with host-only availability", + { + rescheduleUid: input.rescheduleUid, + error: error instanceof Error ? error.message : "Unknown error", + } + ); + } + } + const isTeamEvent = eventType.schedulingType === SchedulingType.COLLECTIVE || eventType.schedulingType === SchedulingType.ROUND_ROBIN ||