From 5266c536cc236cc390515040cda81438fa31c448 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 8 Apr 2026 21:37:56 -0600 Subject: [PATCH 1/2] fix(slots): include guest availability when rescheduling round-robin bookings (#16378) When a guest (attendee who is also a Cal.com user) reschedules a round-robin booking, their availability was not being checked, which could lead to double-bookings. Changes: - Add UserRepository.findAvailabilityUserByEmail() to look up a Cal.com user by email including their credentials and selected calendars - Add AvailableSlotsService._getRescheduledGuestUser() which: - Returns null for collective events (all attendees must attend, so guest availability is irrelevant for slot selection) - Finds the non-organizer attendee from the original booking - Looks up that attendee as a Cal.com user - Returns null if they are not a Cal.com user or their account is locked - Include the rescheduled guest as a fixed host in slot availability calculations (both the main two-week window and the fallback RR path) - Add unit tests covering all edge cases of _getRescheduledGuestUser --- .../users/repositories/UserRepository.ts | 17 +++ .../slots/_getRescheduledGuestUser.test.ts | 135 ++++++++++++++++++ .../trpc/server/routers/viewer/slots/util.ts | 98 ++++++++++++- 3 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 packages/trpc/server/routers/viewer/slots/_getRescheduledGuestUser.test.ts diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index b69b99d5500534..96f8ae67fdafad 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -277,6 +277,23 @@ export class UserRepository { return user; } + async findAvailabilityUserByEmail({ email }: { email: string }) { + const user = await this.prismaClient.user.findUnique({ + where: { + email: email.toLowerCase(), + }, + select: { + locked: true, + ...availabilityUserSelect, + credentials: { + select: credentialForCalendarServiceSelect, + }, + }, + }); + + return user ? withSelectedCalendars(user) : null; + } + async findManyByEmailsWithEmailVerificationSettings({ emails }: { emails: string[] }) { const normalizedEmails = emails.map((e) => e.toLowerCase()); diff --git a/packages/trpc/server/routers/viewer/slots/_getRescheduledGuestUser.test.ts b/packages/trpc/server/routers/viewer/slots/_getRescheduledGuestUser.test.ts new file mode 100644 index 00000000000000..7be251a32c95fc --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/_getRescheduledGuestUser.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { SchedulingType } from "@calcom/prisma/enums"; +import type { IAvailableSlotsService } from "./util"; +import { AvailableSlotsService } from "./util"; + +describe("AvailableSlotsService - _getRescheduledGuestUser", () => { + let service: AvailableSlotsService; + let mockDependencies: { + bookingRepo: { + findByUidIncludeEventTypeAttendeesAndUser: ReturnType; + }; + userRepo: { + findAvailabilityUserByEmail: ReturnType; + }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockDependencies = { + bookingRepo: { + findByUidIncludeEventTypeAttendeesAndUser: vi.fn(), + }, + userRepo: { + findAvailabilityUserByEmail: vi.fn(), + }, + }; + + service = new AvailableSlotsService(mockDependencies as unknown as IAvailableSlotsService); + }); + + it("returns null when rescheduleUid is missing", async () => { + const result = await (service as any)._getRescheduledGuestUser({ + rescheduleUid: null, + organizerEmails: ["host@example.com"], + schedulingType: SchedulingType.ROUND_ROBIN, + }); + + expect(result).toBeNull(); + expect(mockDependencies.bookingRepo.findByUidIncludeEventTypeAttendeesAndUser).not.toHaveBeenCalled(); + }); + + it("returns null for collective events", async () => { + const result = await (service as any)._getRescheduledGuestUser({ + rescheduleUid: "booking-uid", + organizerEmails: ["host@example.com"], + schedulingType: SchedulingType.COLLECTIVE, + }); + + expect(result).toBeNull(); + expect(mockDependencies.bookingRepo.findByUidIncludeEventTypeAttendeesAndUser).not.toHaveBeenCalled(); + }); + + it("returns null when booking is not found", async () => { + mockDependencies.bookingRepo.findByUidIncludeEventTypeAttendeesAndUser.mockResolvedValue(null); + + const result = await (service as any)._getRescheduledGuestUser({ + rescheduleUid: "booking-uid", + organizerEmails: ["host@example.com"], + schedulingType: SchedulingType.ROUND_ROBIN, + }); + + expect(result).toBeNull(); + expect(mockDependencies.bookingRepo.findByUidIncludeEventTypeAttendeesAndUser).toHaveBeenCalledWith({ + bookingUid: "booking-uid", + }); + }); + + it("returns null when attendee is not a Cal.com user", async () => { + mockDependencies.bookingRepo.findByUidIncludeEventTypeAttendeesAndUser.mockResolvedValue({ + attendees: [{ email: "guest@example.com" }], + }); + mockDependencies.userRepo.findAvailabilityUserByEmail.mockResolvedValue(null); + + const result = await (service as any)._getRescheduledGuestUser({ + rescheduleUid: "booking-uid", + organizerEmails: ["host@example.com"], + schedulingType: SchedulingType.ROUND_ROBIN, + }); + + expect(result).toBeNull(); + expect(mockDependencies.userRepo.findAvailabilityUserByEmail).toHaveBeenCalledWith({ + email: "guest@example.com", + }); + }); + + it("returns null when attendee user is locked", async () => { + mockDependencies.bookingRepo.findByUidIncludeEventTypeAttendeesAndUser.mockResolvedValue({ + attendees: [{ email: "guest@example.com" }], + }); + mockDependencies.userRepo.findAvailabilityUserByEmail.mockResolvedValue({ + id: 42, + email: "guest@example.com", + locked: true, + credentials: [], + }); + + const result = await (service as any)._getRescheduledGuestUser({ + rescheduleUid: "booking-uid", + organizerEmails: ["host@example.com"], + schedulingType: SchedulingType.ROUND_ROBIN, + }); + + expect(result).toBeNull(); + }); + + it("excludes organizer emails and returns the guest availability user", async () => { + mockDependencies.bookingRepo.findByUidIncludeEventTypeAttendeesAndUser.mockResolvedValue({ + attendees: [{ email: "HOST@example.com" }, { email: "guest@example.com" }], + }); + mockDependencies.userRepo.findAvailabilityUserByEmail.mockResolvedValue({ + id: 42, + email: "guest@example.com", + locked: false, + credentials: [{ id: 1, key: {} }], + timeZone: "UTC", + }); + + const result = await (service as any)._getRescheduledGuestUser({ + rescheduleUid: "booking-uid", + organizerEmails: ["host@example.com"], + schedulingType: SchedulingType.ROUND_ROBIN, + }); + + expect(mockDependencies.userRepo.findAvailabilityUserByEmail).toHaveBeenCalledWith({ + email: "guest@example.com", + }); + expect(result).toMatchObject({ + id: 42, + email: "guest@example.com", + credentials: [{ id: 1, key: {} }], + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 433672093bbef3..9b9d0b155ad341 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -846,6 +846,55 @@ export class AvailableSlotsService { "getUsersWithCredentials" ); + private async _getRescheduledGuestUser({ + rescheduleUid, + organizerEmails, + schedulingType, + }: { + rescheduleUid?: string | null; + organizerEmails: string[]; + schedulingType: SchedulingType; + }): Promise { + if (!rescheduleUid || schedulingType === SchedulingType.COLLECTIVE) { + return null; + } + + const booking = await this.dependencies.bookingRepo.findByUidIncludeEventTypeAttendeesAndUser({ + bookingUid: rescheduleUid, + }); + + if (!booking) { + return null; + } + + const attendeeToCheck = booking.attendees.find((attendee) => { + const attendeeEmail = attendee.email.toLowerCase(); + return !organizerEmails.includes(attendeeEmail); + }); + + if (!attendeeToCheck?.email) { + return null; + } + + const attendeeUser = await this.dependencies.userRepo.findAvailabilityUserByEmail({ + email: attendeeToCheck.email, + }); + + if (!attendeeUser || attendeeUser.locked) { + return null; + } + + return { + ...attendeeUser, + credentials: attendeeUser.credentials ?? [], + }; + } + + private getRescheduledGuestUser = withReporting( + this._getRescheduledGuestUser.bind(this), + "getRescheduledGuestUser" + ); + private getStartTime(startTimeInput: string, timeZone?: string, minimumBookingNotice?: number) { const startTimeMin = dayjs.utc().add(minimumBookingNotice || 1, "minutes"); const startTime = timeZone === "Etc/GMT" ? dayjs.utc(startTimeInput) : dayjs(startTimeInput).tz(timeZone); @@ -1247,6 +1296,25 @@ export class AvailableSlotsService { }; } + const organizerEmails = Array.from( + new Set(allHosts.map((host) => host.user.email.toLowerCase()).filter(Boolean)) + ); + const rescheduledGuestUser = await this.getRescheduledGuestUser({ + rescheduleUid: input.rescheduleUid, + organizerEmails, + schedulingType: eventType.schedulingType, + }); + const allHostsAndRescheduledGuest = rescheduledGuestUser + ? [ + ...allHosts, + { + isFixed: true, + groupId: null, + user: rescheduledGuestUser, + }, + ] + : allHosts; + const twoWeeksFromNow = dayjs().add(2, "week"); const hasFallbackRRHosts = @@ -1256,7 +1324,7 @@ export class AvailableSlotsService { await this.calculateHostsAndAvailabilities({ input, eventType, - hosts: allHosts, + hosts: allHostsAndRescheduledGuest, loggerWithEventDetails, // adjust start time so we can check for available slots in the first two weeks startTime: @@ -1291,7 +1359,19 @@ export class AvailableSlotsService { const firstTwoWeeksAvailabilities = await this.calculateHostsAndAvailabilities({ input, eventType, - hosts: [...eligibleQualifiedRRHosts, ...eligibleFixedHosts], + hosts: [ + ...eligibleQualifiedRRHosts, + ...eligibleFixedHosts, + ...(rescheduledGuestUser + ? [ + { + isFixed: true, + groupId: null, + user: rescheduledGuestUser, + }, + ] + : []), + ], loggerWithEventDetails, startTime: dayjs(), endTime: twoWeeksFromNow, @@ -1327,7 +1407,19 @@ export class AvailableSlotsService { await this.calculateHostsAndAvailabilities({ input, eventType, - hosts: [...eligibleFallbackRRHosts, ...eligibleFixedHosts], + hosts: [ + ...eligibleFallbackRRHosts, + ...eligibleFixedHosts, + ...(rescheduledGuestUser + ? [ + { + isFixed: true, + groupId: null, + user: rescheduledGuestUser, + }, + ] + : []), + ], loggerWithEventDetails, startTime, endTime, From 02a2ccca48958dc3a6543287b250c80621093680 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 8 Apr 2026 22:45:04 -0600 Subject: [PATCH 2/2] test(slots): prove rescheduled guest is included in RR availability flow --- ..._getAvailableSlots.rescheduleGuest.test.ts | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 packages/trpc/server/routers/viewer/slots/_getAvailableSlots.rescheduleGuest.test.ts diff --git a/packages/trpc/server/routers/viewer/slots/_getAvailableSlots.rescheduleGuest.test.ts b/packages/trpc/server/routers/viewer/slots/_getAvailableSlots.rescheduleGuest.test.ts new file mode 100644 index 00000000000000..78626a2fdde88b --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/_getAvailableSlots.rescheduleGuest.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import dayjs from "@calcom/dayjs"; +import { PeriodType, SchedulingType } from "@calcom/prisma/enums"; + +vi.mock("@calcom/features/watchlist/operations/filter-blocked-hosts.controller", () => ({ + filterBlockedHosts: vi.fn(async (hosts: unknown[]) => ({ eligibleHosts: hosts })), +})); + +import type { IAvailableSlotsService } from "./util"; +import { AvailableSlotsService } from "./util"; + +describe("AvailableSlotsService - rescheduled guest is injected into slot calculation", () => { + let service: AvailableSlotsService; + let mockDependencies: { + redisClient: {}; + qualifiedHostsService: { + findQualifiedHostsWithDelegationCredentials: ReturnType; + }; + }; + + const qualifiedHost = { + isFixed: false, + groupId: "rr-1", + user: { + id: 10, + email: "host@example.com", + timeZone: "UTC", + credentials: [], + }, + }; + + const fallbackHost = { + isFixed: false, + groupId: "rr-2", + user: { + id: 11, + email: "fallback@example.com", + timeZone: "UTC", + credentials: [], + }, + }; + + const rescheduledGuestUser = { + id: 20, + email: "guest@example.com", + timeZone: "UTC", + credentials: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockDependencies = { + redisClient: {}, + qualifiedHostsService: { + findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({ + qualifiedRRHosts: [qualifiedHost], + allFallbackRRHosts: [qualifiedHost, fallbackHost], + fixedHosts: [], + }), + }, + }; + + service = new AvailableSlotsService(mockDependencies as unknown as IAvailableSlotsService); + + (service as any).getRegularOrDynamicEventType = vi.fn().mockResolvedValue({ + id: 123, + schedulingType: SchedulingType.ROUND_ROBIN, + periodType: PeriodType.UNLIMITED, + minimumBookingNotice: 0, + length: 30, + offsetStart: 0, + slotInterval: 30, + showOptimizedSlots: false, + team: null, + restrictionScheduleId: null, + useBookerTimezone: false, + }); + + (service as any).resolveOrganizationIdForBlocking = vi.fn().mockResolvedValue(null); + (service as any).getRescheduledGuestUser = vi.fn().mockResolvedValue(rescheduledGuestUser); + (service as any).checkRestrictionScheduleEnabled = vi.fn().mockResolvedValue(false); + (service as any)._getReservedSlotsAndCleanupExpired = vi.fn().mockResolvedValue([]); + }); + + it("passes the guest into both the initial and fallback round-robin host calculations", async () => { + const calculateHostsAndAvailabilities = vi + .fn() + .mockResolvedValueOnce({ + allUsersAvailability: [], + usersWithCredentials: [], + currentSeats: undefined, + }) + .mockResolvedValueOnce({ + allUsersAvailability: [], + usersWithCredentials: [], + currentSeats: undefined, + }) + .mockResolvedValueOnce({ + allUsersAvailability: [], + usersWithCredentials: [], + currentSeats: undefined, + }); + + (service as any).calculateHostsAndAvailabilities = calculateHostsAndAvailabilities; + + await (service as any)._getAvailableSlots({ + input: { + startTime: dayjs().add(20, "day").toISOString(), + endTime: dayjs().add(21, "day").toISOString(), + timeZone: "UTC", + eventTypeSlug: "demo", + usernameList: ["host"], + duration: 30, + rescheduleUid: "booking-uid", + }, + ctx: undefined, + }); + + expect((service as any).getRescheduledGuestUser).toHaveBeenCalledWith({ + rescheduleUid: "booking-uid", + organizerEmails: ["host@example.com"], + schedulingType: SchedulingType.ROUND_ROBIN, + }); + + expect(calculateHostsAndAvailabilities).toHaveBeenCalledTimes(3); + + const firstCallHosts = calculateHostsAndAvailabilities.mock.calls[0][0].hosts; + const firstTwoWeeksHosts = calculateHostsAndAvailabilities.mock.calls[1][0].hosts; + const fallbackCallHosts = calculateHostsAndAvailabilities.mock.calls[2][0].hosts; + + expect(firstCallHosts.map((host: any) => host.user.email)).toEqual([ + "host@example.com", + "guest@example.com", + ]); + + expect(firstTwoWeeksHosts.map((host: any) => host.user.email)).toEqual([ + "host@example.com", + "guest@example.com", + ]); + + expect(fallbackCallHosts.map((host: any) => host.user.email)).toEqual([ + "host@example.com", + "fallback@example.com", + "guest@example.com", + ]); + }); +});