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/_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", + ]); + }); +}); 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,