From d4de6e25524512775d80e7f0d204c2f27104199a Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Fri, 27 Mar 2026 10:04:25 +0700 Subject: [PATCH 01/29] feat: add findByEmails to UserRepository Looks up Cal.com users by email, checking both primary and verified secondary emails with case-insensitive matching. Deduplicates by user id when the same user appears via both lookup paths. --- .../users/repositories/UserRepository.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index 24878a56846295..1652ae99a2a3e3 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -1506,4 +1506,33 @@ export class UserRepository { return { email: user.email, username: user.username }; } + + async findByEmails({ emails }: { emails: string[] }) { + if (!emails.length) return []; + + const normalized = emails.map((e) => e.toLowerCase()); + + const byPrimary = await this.prismaClient.user.findMany({ + where: { email: { in: normalized, mode: "insensitive" } }, + select: { id: true, email: true }, + }); + + const bySecondary = await 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()); + } } From bd9929d0743ab4b70637f55a8f1f7244ad576eb3 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Fri, 27 Mar 2026 10:04:25 +0700 Subject: [PATCH 02/29] feat: add booking lookup methods for guest availability findByUidIncludeAttendeeEmails retrieves the original booking's attendee list. findByUserIdsAndDateRange fetches a user's accepted and pending bookings in a date window. --- .../repositories/BookingRepository.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index 96b20fdf373215..f501781a8e7bc5 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -2137,4 +2137,50 @@ 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 } }, + }, + }); + } + + async findByUserIdsAndDateRange({ + userIds, + userEmails, + dateFrom, + dateTo, + }: { + userIds: number[]; + userEmails: string[]; + dateFrom: Date; + dateTo: Date; + }) { + 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 } } } }] + : []), + ], + }, + select: { + uid: true, + startTime: true, + endTime: true, + title: true, + userId: true, + status: true, + }, + }); + } } From 4a761e67f83c5cfbf7fb0b88ca48e62130030ad5 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Fri, 27 Mar 2026 10:04:46 +0700 Subject: [PATCH 03/29] feat: merge guest busy times into availability computation Adds guestBusyTimes field to GetUserAvailabilityInitialData and includes them in the combined busy times array so that slots overlapping with the guest's existing bookings are filtered out. --- .../features/availability/lib/getUserAvailability.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/features/availability/lib/getUserAvailability.ts b/packages/features/availability/lib/getUserAvailability.ts index eb94e638d0bc9d..2c5e0c8b09995d 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,15 @@ export class UserAvailabilityService { }; } + const guestBusyTimesFormatted: EventBusyDetails[] = (initialData?.guestBusyTimes ?? []).map( + (t) => ({ + start: dayjs(t.start).toISOString(), + end: dayjs(t.end).toISOString(), + title: "Guest busy", + source: withSource ? "guest-availability" : undefined, + }) + ); + const detailedBusyTimesWithSource: EventBusyDetails[] = [ ...busyTimes.map((a) => ({ ...a, @@ -627,6 +637,7 @@ export class UserAvailabilityService { })), ...busyTimesFromLimits, ...busyTimesFromTeamLimits, + ...guestBusyTimesFormatted, ]; const detailedBusyTimes: UserAvailabilityBusyDetails[] = withSource From 4d728e176f90317924469a6ebcb49589854af66e Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Fri, 27 Mar 2026 10:04:46 +0700 Subject: [PATCH 04/29] feat: check guest availability when host reschedules (#16378) When a host reschedules a booking, look up the attendee emails to see if they belong to Cal.com users. If so, fetch their bookings for the date range and pass them as guest busy times into the availability engine. Skips the rescheduled booking itself so its original slot remains selectable. Runs in parallel with existing data fetches to avoid adding latency. --- .../trpc/server/routers/viewer/slots/util.ts | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 842f97fea3c5c4..6304078bc01a2b 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -652,6 +652,55 @@ 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, + schedulingType, + dateFrom, + dateTo, + }: { + rescheduleUid: string | null | undefined; + schedulingType: SchedulingType | null; + dateFrom: Date; + dateTo: Date; + }): Promise<{ start: Date; end: Date }[]> { + if (!rescheduleUid || schedulingType === SchedulingType.COLLECTIVE) { + return []; + } + + const original = await this.dependencies.bookingRepo.findByUidIncludeAttendeeEmails({ + uid: rescheduleUid, + }); + if (!original?.attendees?.length) 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 []; + + const guestBookings = await this.dependencies.bookingRepo.findByUserIdsAndDateRange({ + userIds: calUsers.map((u) => u.id), + userEmails: calUsers.map((u) => u.email), + dateFrom, + dateTo, + }); + + // Keep the rescheduled booking's own slot available + return guestBookings + .filter((b) => b.uid !== rescheduleUid) + .map((b) => ({ start: b.startTime, end: b.endTime })); + } + private getGuestBusyTimesForReschedule = withReporting( + this._getGuestBusyTimesForReschedule.bind(this), + "getGuestBusyTimesForReschedule" + ); + private _getUsersWithCredentials({ hosts, }: { @@ -726,7 +775,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, @@ -735,6 +784,12 @@ export class AvailableSlotsService { userIdAndEmailMap, }), this.getOOODates(startTimeDate, endTimeDate, allUserIds), + this.getGuestBusyTimesForReschedule({ + rescheduleUid: input.rescheduleUid, + schedulingType: eventType.schedulingType, + dateFrom: startTimeDate, + dateTo: endTimeDate, + }), ]); const bookingLimits = @@ -825,6 +880,7 @@ export class AvailableSlotsService { busyTimesFromLimitsBookings: busyTimesFromLimitsBookingsAllUsers, busyTimesFromLimits: busyTimesFromLimitsMap, eventTypeForLimits: eventType && (bookingLimits || durationLimits) ? eventType : null, + guestBusyTimes, }, }); /* We get all users working hours and busy slots */ From 0d29c21841348bfb5b27ddae279cd1054b51dbf1 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Fri, 27 Mar 2026 19:23:04 +0000 Subject: [PATCH 05/29] fix: address review feedback for guest availability rescheduling - Use original attendee emails for busy-time lookup instead of resolved primary emails, fixing missed bookings made with secondary emails - Parallelize primary/secondary email queries with Promise.all - Deduplicate normalized emails before querying - Use dayjs.utc() for guest busy time formatting (perf) --- .../availability/lib/getUserAvailability.ts | 4 +-- .../users/repositories/UserRepository.ts | 31 ++++++++++--------- .../trpc/server/routers/viewer/slots/util.ts | 2 +- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/features/availability/lib/getUserAvailability.ts b/packages/features/availability/lib/getUserAvailability.ts index 2c5e0c8b09995d..4aa7588b06b77a 100644 --- a/packages/features/availability/lib/getUserAvailability.ts +++ b/packages/features/availability/lib/getUserAvailability.ts @@ -620,8 +620,8 @@ export class UserAvailabilityService { const guestBusyTimesFormatted: EventBusyDetails[] = (initialData?.guestBusyTimes ?? []).map( (t) => ({ - start: dayjs(t.start).toISOString(), - end: dayjs(t.end).toISOString(), + start: dayjs.utc(t.start).toISOString(), + end: dayjs.utc(t.end).toISOString(), title: "Guest busy", source: withSource ? "guest-availability" : undefined, }) diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index 1652ae99a2a3e3..863937267c9b6f 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -1510,24 +1510,25 @@ export class UserRepository { async findByEmails({ emails }: { emails: string[] }) { if (!emails.length) return []; - const normalized = emails.map((e) => e.toLowerCase()); + const normalized = [...new Set(emails.map((e) => e.toLowerCase()))]; - const byPrimary = await this.prismaClient.user.findMany({ - where: { email: { in: normalized, mode: "insensitive" } }, - select: { id: true, email: true }, - }); - - const bySecondary = await this.prismaClient.user.findMany({ - where: { - secondaryEmails: { - some: { - email: { in: normalized, mode: "insensitive" }, - emailVerified: { not: null }, + 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 }, - }); + select: { id: true, email: true }, + }), + ]); const seen = new Map(); for (const u of [...byPrimary, ...bySecondary]) { diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 6304078bc01a2b..3662c01bbd7a06 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -686,7 +686,7 @@ export class AvailableSlotsService { const guestBookings = await this.dependencies.bookingRepo.findByUserIdsAndDateRange({ userIds: calUsers.map((u) => u.id), - userEmails: calUsers.map((u) => u.email), + userEmails: emails, dateFrom, dateTo, }); From d558910fbb184c62b68716f846c45d2f10a20cbe Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Sun, 29 Mar 2026 00:24:12 +0700 Subject: [PATCH 06/29] test: add unit tests for guest availability during reschedule Add tests covering the new guest busy time feature: - BookingRepository: findByUidIncludeAttendeeEmails and findByUserIdsAndDateRange - UserRepository: findByEmails (primary + secondary email lookup, dedup, normalization) - AvailableSlotsService: _getGuestBusyTimesForReschedule (early exits, busy time collection, rescheduled booking exclusion, multi-guest handling) --- .../repositories/BookingRepository.test.ts | 161 +++++++++ .../users/repositories/UserRepository.test.ts | 101 ++++++ .../getGuestBusyTimesForReschedule.test.ts | 314 ++++++++++++++++++ 3 files changed, 576 insertions(+) create mode 100644 packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts diff --git a/packages/features/bookings/repositories/BookingRepository.test.ts b/packages/features/bookings/repositories/BookingRepository.test.ts index 965673ad009001..a9e2c4a85044ab 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,156 @@ describe("BookingRepository", () => { expect(mockPrismaClient.$queryRaw).toHaveBeenCalledTimes(1); }); }); + + describe("findByUidIncludeAttendeeEmails", () => { + it("should query booking by uid with attendee emails", async () => { + const mockBooking = { + id: 1, + uid: "test-uid", + attendees: [{ email: "guest@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 } }, + }, + }); + }); + + 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, + }, + }) + ); + }); + }); }); diff --git a/packages/features/users/repositories/UserRepository.test.ts b/packages/features/users/repositories/UserRepository.test.ts index 44e7001c96834d..10505ea52eefff 100644 --- a/packages/features/users/repositories/UserRepository.test.ts +++ b/packages/features/users/repositories/UserRepository.test.ts @@ -1,5 +1,6 @@ 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", () => ({ @@ -111,4 +112,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/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts new file mode 100644 index 00000000000000..2e56a71574183f --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts @@ -0,0 +1,314 @@ +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"; + + 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; + 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: [], + }); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + 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" }], + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([]); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + schedulingType: null, + dateFrom, + dateTo, + }); + + expect(result).toEqual([]); + expect(mockDependencies.bookingRepo.findByUserIdsAndDateRange).not.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" }], + }); + 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, + 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 exclude the rescheduled booking itself from busy times", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + attendees: [{ email: "guest@cal.com" }], + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); + mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([ + { + uid: rescheduleUid, + startTime: new Date("2026-04-10T14:00:00Z"), + endTime: new Date("2026-04-10T15:00:00Z"), + title: "Original meeting", + userId: 10, + status: "ACCEPTED", + }, + { + 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, + schedulingType: null, + dateFrom, + dateTo, + }); + + 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 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" }], + }); + 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, + 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, + }); + }); + + it("should work with ROUND_ROBIN scheduling type", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + attendees: [{ email: "guest@cal.com" }], + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); + mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + 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" }], + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([ + { id: 42, email: "cal-user@example.com" }, + ]); + mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); + + await callGetGuestBusyTimes({ + rescheduleUid, + 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", "external@gmail.com"], + dateFrom, + dateTo, + }); + }); + }); +}); From 4475b4fe3fcbf3c667efbdaf9cba22f73a21cfe9 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Sun, 29 Mar 2026 00:24:12 +0700 Subject: [PATCH 07/29] refactor: improve error handling and move filtering to database level - Add try/catch with graceful degradation in _getGuestBusyTimesForReschedule: errors return empty array, never blocking the reschedule flow - Move excludeUid filtering from JS to database query level for efficiency - Add excludeUid parameter to BookingRepository.findByUserIdsAndDateRange - Update tests: verify excludeUid is passed to DB, add error handling test --- .../repositories/BookingRepository.test.ts | 19 ++++++++ .../repositories/BookingRepository.ts | 3 ++ .../getGuestBusyTimesForReschedule.test.ts | 28 ++++++++---- .../trpc/server/routers/viewer/slots/util.ts | 43 ++++++++++--------- 4 files changed, 64 insertions(+), 29 deletions(-) diff --git a/packages/features/bookings/repositories/BookingRepository.test.ts b/packages/features/bookings/repositories/BookingRepository.test.ts index a9e2c4a85044ab..099852aaff0226 100644 --- a/packages/features/bookings/repositories/BookingRepository.test.ts +++ b/packages/features/bookings/repositories/BookingRepository.test.ts @@ -218,5 +218,24 @@ describe("BookingRepository", () => { }) ); }); + + it("should include excludeUid in query when provided", async () => { + const repo = new BookingRepository(mockPrisma as unknown as PrismaClient); + await repo.findByUserIdsAndDateRange({ + userIds: [1], + userEmails: [], + dateFrom, + dateTo, + excludeUid: "booking-to-exclude", + }); + + expect(mockPrisma.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 f501781a8e7bc5..9709c429c66a14 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -2154,11 +2154,13 @@ export class BookingRepository implements IBookingRepository { userEmails, dateFrom, dateTo, + excludeUid, }: { userIds: number[]; userEmails: string[]; dateFrom: Date; dateTo: Date; + excludeUid?: string; }) { if (!userIds.length && !userEmails.length) return []; @@ -2172,6 +2174,7 @@ export class BookingRepository implements IBookingRepository { ? [{ attendees: { some: { email: { in: userEmails, mode: "insensitive" as const } } } }] : []), ], + ...(excludeUid ? { uid: { not: excludeUid } } : {}), }, select: { uid: true, diff --git a/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts index 2e56a71574183f..cc731df228ff8e 100644 --- a/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts +++ b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts @@ -172,7 +172,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { ]); }); - it("should exclude the rescheduled booking itself from busy times", async () => { + it("should pass excludeUid to the booking query to filter at database level", async () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, uid: rescheduleUid, @@ -180,14 +180,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { }); mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([ - { - uid: rescheduleUid, - startTime: new Date("2026-04-10T14:00:00Z"), - endTime: new Date("2026-04-10T15:00:00Z"), - title: "Original meeting", - userId: 10, - status: "ACCEPTED", - }, { uid: "different-booking", startTime: new Date("2026-04-10T16:00:00Z"), @@ -205,6 +197,9 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { 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"), @@ -212,6 +207,21 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { }); }); + 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 handle multiple guest attendees who are Cal.com users", async () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 3662c01bbd7a06..6dc2874b6a9893 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -671,30 +671,33 @@ export class AvailableSlotsService { return []; } - const original = await this.dependencies.bookingRepo.findByUidIncludeAttendeeEmails({ - uid: rescheduleUid, - }); - if (!original?.attendees?.length) return []; + try { + const original = await this.dependencies.bookingRepo.findByUidIncludeAttendeeEmails({ + uid: rescheduleUid, + }); + if (!original?.attendees?.length) return []; - const emails = original.attendees - .map((a) => a.email) - .filter((e): e is string => Boolean(e)); - if (!emails.length) 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 []; + const calUsers = await this.dependencies.userRepo.findByEmails({ emails }); + if (!calUsers.length) return []; - const guestBookings = await this.dependencies.bookingRepo.findByUserIdsAndDateRange({ - userIds: calUsers.map((u) => u.id), - userEmails: emails, - dateFrom, - dateTo, - }); + const guestBookings = await this.dependencies.bookingRepo.findByUserIdsAndDateRange({ + userIds: calUsers.map((u) => u.id), + userEmails: emails, + dateFrom, + dateTo, + excludeUid: rescheduleUid, + }); - // Keep the rescheduled booking's own slot available - return guestBookings - .filter((b) => b.uid !== rescheduleUid) - .map((b) => ({ start: b.startTime, end: b.endTime })); + return guestBookings.map((b) => ({ start: b.startTime, end: b.endTime })); + } catch (error) { + // Graceful degradation: never block rescheduling if guest lookup fails + return []; + } } private getGuestBusyTimesForReschedule = withReporting( this._getGuestBusyTimesForReschedule.bind(this), From a2b98d7722afb3edaac376786535f80f9df71780 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Sun, 29 Mar 2026 02:32:34 +0700 Subject: [PATCH 08/29] fix: add host-initiator gating and narrow email filter Addresses two issues identified by cubic-dev-ai review: 1. Host-initiator gating: Guest busy-time check now only applies when the event type host is rescheduling. If the booking's userId is not in the current event's host list, it's an attendee-initiated reschedule and all slots are shown (per CarinaWolli's spec). 2. Narrow email filter: The OR attendee-email condition in findByUserIdsAndDateRange now only includes emails of resolved Cal.com users, not all original attendee emails. This prevents pulling in bookings for non-Cal.com guests. Added tests: - Skip guest check when attendee (not host) reschedules - Verify only Cal.com user emails used in booking query --- .../getGuestBusyTimesForReschedule.test.ts | 78 +++++++++++++++++++ .../trpc/server/routers/viewer/slots/util.ts | 16 +++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts index cc731df228ff8e..59199c8e7248f7 100644 --- a/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts +++ b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts @@ -40,6 +40,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const callGetGuestBusyTimes = (params: { rescheduleUid: string | null | undefined; schedulingType: SchedulingType | null; + hostUserIds?: number[]; dateFrom: Date; dateTo: Date; }) => @@ -54,6 +55,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid: null, schedulingType: null, + hostUserIds: [1], dateFrom, dateTo, }); @@ -66,6 +68,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid: undefined, schedulingType: null, + hostUserIds: [1], dateFrom, dateTo, }); @@ -78,6 +81,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: SchedulingType.COLLECTIVE, + hostUserIds: [1], dateFrom, dateTo, }); @@ -90,12 +94,14 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, uid: rescheduleUid, + userId: 1, attendees: [], }); const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, + hostUserIds: [1], dateFrom, dateTo, }); @@ -110,6 +116,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, + hostUserIds: [1], dateFrom, dateTo, }); @@ -122,6 +129,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, uid: rescheduleUid, + userId: 1, attendees: [{ email: "external@gmail.com" }], }); mockDependencies.userRepo.findByEmails.mockResolvedValue([]); @@ -129,6 +137,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, + hostUserIds: [1], dateFrom, dateTo, }); @@ -143,6 +152,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, uid: rescheduleUid, + userId: 1, attendees: [{ email: "guest@cal.com" }], }); mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); @@ -160,6 +170,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, + hostUserIds: [1], dateFrom, dateTo, }); @@ -176,6 +187,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, uid: rescheduleUid, + userId: 1, attendees: [{ email: "guest@cal.com" }], }); mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); @@ -193,6 +205,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, + hostUserIds: [1], dateFrom, dateTo, }); @@ -215,6 +228,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, + hostUserIds: [1], dateFrom, dateTo, }); @@ -222,10 +236,68 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { expect(result).toEqual([]); }); + it("should skip guest check when attendee (not host) reschedules", async () => { + // Booking was created by user 1, but current event hosts are [99] + // This means the attendee is rescheduling, not the host + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + userId: 1, + attendees: [{ email: "guest@cal.com" }], + }); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + schedulingType: null, + hostUserIds: [99], // Not the booking host + dateFrom, + dateTo, + }); + + expect(result).toEqual([]); + expect(mockDependencies.userRepo.findByEmails).not.toHaveBeenCalled(); + }); + + it("should only use Cal.com user emails in booking query, not all attendee emails", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + userId: 1, + attendees: [ + { email: "caluser@cal.com" }, + { email: "external@gmail.com" }, + ], + }); + // Only caluser@cal.com is a Cal.com user + mockDependencies.userRepo.findByEmails.mockResolvedValue([ + { id: 10, email: "caluser@cal.com" }, + ]); + mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); + + await callGetGuestBusyTimes({ + rescheduleUid, + schedulingType: null, + hostUserIds: [1], + dateFrom, + dateTo, + }); + + // userEmails should only contain the Cal.com user's email + expect(mockDependencies.bookingRepo.findByUserIdsAndDateRange).toHaveBeenCalledWith( + expect.objectContaining({ + userEmails: ["caluser@cal.com"], + }) + ); + // Should NOT contain external@gmail.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, + userId: 1, attendees: [{ email: "guest1@cal.com" }, { email: "guest2@cal.com" }], }); mockDependencies.userRepo.findByEmails.mockResolvedValue([ @@ -254,6 +326,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, + hostUserIds: [1], dateFrom, dateTo, }); @@ -274,6 +347,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, uid: rescheduleUid, + userId: 1, attendees: [{ email: "guest@cal.com" }], }); mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); @@ -282,6 +356,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: SchedulingType.ROUND_ROBIN, + hostUserIds: [1], dateFrom, dateTo, }); @@ -289,6 +364,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { expect(result).toEqual([]); expect(mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails).toHaveBeenCalledWith({ uid: rescheduleUid, + userId: 1, }); }); @@ -296,6 +372,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, uid: rescheduleUid, + userId: 1, attendees: [{ email: "cal-user@example.com" }, { email: "external@gmail.com" }], }); mockDependencies.userRepo.findByEmails.mockResolvedValue([ @@ -306,6 +383,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, + hostUserIds: [1], dateFrom, dateTo, }); diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 6dc2874b6a9893..f6e81996a448bd 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -659,11 +659,13 @@ export class AvailableSlotsService { private async _getGuestBusyTimesForReschedule({ rescheduleUid, schedulingType, + hostUserIds, dateFrom, dateTo, }: { rescheduleUid: string | null | undefined; schedulingType: SchedulingType | null; + hostUserIds: number[]; dateFrom: Date; dateTo: Date; }): Promise<{ start: Date; end: Date }[]> { @@ -677,6 +679,13 @@ export class AvailableSlotsService { }); if (!original?.attendees?.length) return []; + // Only apply guest availability check when the host is rescheduling. + // If the booking's host is not in the current event type's host list, + // this is an attendee-initiated reschedule — show all slots. + if (original.userId && !hostUserIds.includes(original.userId)) { + return []; + } + const emails = original.attendees .map((a) => a.email) .filter((e): e is string => Boolean(e)); @@ -685,9 +694,13 @@ export class AvailableSlotsService { 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: emails, + userEmails: calUserEmails, dateFrom, dateTo, excludeUid: rescheduleUid, @@ -790,6 +803,7 @@ export class AvailableSlotsService { this.getGuestBusyTimesForReschedule({ rescheduleUid: input.rescheduleUid, schedulingType: eventType.schedulingType, + hostUserIds: allUserIds, dateFrom: startTimeDate, dateTo: endTimeDate, }), From b5454260de3178f149e1443e4ef61ef9c6f87eb3 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Sun, 29 Mar 2026 11:35:12 +0700 Subject: [PATCH 09/29] fix: remove broken initiator gating, fix test contract conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses cubic-dev-ai's second review: 1. Remove hostUserIds-based initiator detection — it was incorrect because the booking's userId (host) is always in the event type's host list, making the check a no-op. The slots API does not receive rescheduledBy context, so host-vs-attendee gating cannot be done at this layer. Guest availability is now always checked as the safe default (fewer slots > double-booking risk). Added inline comment explaining this design decision and the path forward. 2. Fix test contract conflict — the "pass correct userIds and emails" test now correctly expects only Cal.com user emails in userEmails, consistent with the email filtering fix. Removed the broken "skip when attendee reschedules" test since the gating was removed. --- .../getGuestBusyTimesForReschedule.test.ts | 40 ++----------------- .../trpc/server/routers/viewer/slots/util.ts | 16 ++++---- 2 files changed, 10 insertions(+), 46 deletions(-) diff --git a/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts index 59199c8e7248f7..34017e84d2b33b 100644 --- a/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts +++ b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts @@ -40,7 +40,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const callGetGuestBusyTimes = (params: { rescheduleUid: string | null | undefined; schedulingType: SchedulingType | null; - hostUserIds?: number[]; dateFrom: Date; dateTo: Date; }) => @@ -55,7 +54,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid: null, schedulingType: null, - hostUserIds: [1], dateFrom, dateTo, }); @@ -68,7 +66,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid: undefined, schedulingType: null, - hostUserIds: [1], dateFrom, dateTo, }); @@ -81,7 +78,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: SchedulingType.COLLECTIVE, - hostUserIds: [1], dateFrom, dateTo, }); @@ -101,7 +97,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, - hostUserIds: [1], dateFrom, dateTo, }); @@ -116,7 +111,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, - hostUserIds: [1], dateFrom, dateTo, }); @@ -137,7 +131,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, - hostUserIds: [1], dateFrom, dateTo, }); @@ -170,7 +163,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, - hostUserIds: [1], dateFrom, dateTo, }); @@ -205,7 +197,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, - hostUserIds: [1], dateFrom, dateTo, }); @@ -228,7 +219,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, - hostUserIds: [1], dateFrom, dateTo, }); @@ -236,28 +226,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { expect(result).toEqual([]); }); - it("should skip guest check when attendee (not host) reschedules", async () => { - // Booking was created by user 1, but current event hosts are [99] - // This means the attendee is rescheduling, not the host - mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ - id: 1, - uid: rescheduleUid, - userId: 1, - attendees: [{ email: "guest@cal.com" }], - }); - - const result = await callGetGuestBusyTimes({ - rescheduleUid, - schedulingType: null, - hostUserIds: [99], // Not the booking host - dateFrom, - dateTo, - }); - - expect(result).toEqual([]); - expect(mockDependencies.userRepo.findByEmails).not.toHaveBeenCalled(); - }); - it("should only use Cal.com user emails in booking query, not all attendee emails", async () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, @@ -277,7 +245,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, - hostUserIds: [1], dateFrom, dateTo, }); @@ -326,7 +293,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, - hostUserIds: [1], dateFrom, dateTo, }); @@ -356,7 +322,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, schedulingType: SchedulingType.ROUND_ROBIN, - hostUserIds: [1], dateFrom, dateTo, }); @@ -383,7 +348,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { await callGetGuestBusyTimes({ rescheduleUid, schedulingType: null, - hostUserIds: [1], dateFrom, dateTo, }); @@ -391,11 +355,13 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { expect(mockDependencies.userRepo.findByEmails).toHaveBeenCalledWith({ emails: ["cal-user@example.com", "external@gmail.com"], }); + // userEmails should only contain Cal.com user emails, not external ones expect(mockDependencies.bookingRepo.findByUserIdsAndDateRange).toHaveBeenCalledWith({ userIds: [42], - userEmails: ["cal-user@example.com", "external@gmail.com"], + userEmails: ["cal-user@example.com"], dateFrom, dateTo, + excludeUid: rescheduleUid, }); }); }); diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index f6e81996a448bd..7152f9ea181f3f 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -659,13 +659,11 @@ export class AvailableSlotsService { private async _getGuestBusyTimesForReschedule({ rescheduleUid, schedulingType, - hostUserIds, dateFrom, dateTo, }: { rescheduleUid: string | null | undefined; schedulingType: SchedulingType | null; - hostUserIds: number[]; dateFrom: Date; dateTo: Date; }): Promise<{ start: Date; end: Date }[]> { @@ -679,12 +677,13 @@ export class AvailableSlotsService { }); if (!original?.attendees?.length) return []; - // Only apply guest availability check when the host is rescheduling. - // If the booking's host is not in the current event type's host list, - // this is an attendee-initiated reschedule — show all slots. - if (original.userId && !hostUserIds.includes(original.userId)) { - return []; - } + // Note: The slots API does not receive `rescheduledBy` context, so we + // cannot distinguish host-initiated from attendee-initiated reschedules + // at this layer. We always check guest availability as the safe default: + // showing fewer available slots is preferable to risking double-bookings. + // Per CarinaWolli's spec, attendee reschedules should show all slots — + // if this gating is needed, `rescheduledBy` must be added to the slots + // input schema (a separate change). const emails = original.attendees .map((a) => a.email) @@ -803,7 +802,6 @@ export class AvailableSlotsService { this.getGuestBusyTimesForReschedule({ rescheduleUid: input.rescheduleUid, schedulingType: eventType.schedulingType, - hostUserIds: allUserIds, dateFrom: startTimeDate, dateTo: endTimeDate, }), From 78269db71b43e2933e056bbd81bf3183857721c0 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Sun, 29 Mar 2026 11:41:12 +0700 Subject: [PATCH 10/29] fix: correct variable name in excludeUid test (mockPrisma -> mockPrismaClient) --- .../features/bookings/repositories/BookingRepository.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/features/bookings/repositories/BookingRepository.test.ts b/packages/features/bookings/repositories/BookingRepository.test.ts index 099852aaff0226..2dce2ebff09389 100644 --- a/packages/features/bookings/repositories/BookingRepository.test.ts +++ b/packages/features/bookings/repositories/BookingRepository.test.ts @@ -220,7 +220,7 @@ describe("BookingRepository", () => { }); it("should include excludeUid in query when provided", async () => { - const repo = new BookingRepository(mockPrisma as unknown as PrismaClient); + const repo = new BookingRepository(mockPrismaClient as unknown as PrismaClient); await repo.findByUserIdsAndDateRange({ userIds: [1], userEmails: [], @@ -229,7 +229,7 @@ describe("BookingRepository", () => { excludeUid: "booking-to-exclude", }); - expect(mockPrisma.booking.findMany).toHaveBeenCalledWith( + expect(mockPrismaClient.booking.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ uid: { not: "booking-to-exclude" }, From fa147ce4cef408cc14db05bee678030fe0214e40 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Sun, 29 Mar 2026 19:56:03 +0700 Subject: [PATCH 11/29] fix: gate guest busy-time blocking on host-initiated reschedules only The guest busy-time check was applying to all reschedules regardless of who initiated them. When an attendee reschedules, they should see all available slots without being constrained by other guests' schedules. Changes: - Add rescheduledBy to slots input schema so frontend can pass context - Fetch host user email in findByUidIncludeAttendeeEmails - Compare rescheduledBy with host email to determine initiator - Skip guest blocking when attendee initiates the reschedule - Thread rescheduledBy from useEvent -> useSchedule -> slots API - Update tests with host/attendee gating scenarios --- apps/web/modules/schedules/hooks/useEvent.ts | 2 + .../modules/schedules/hooks/useSchedule.ts | 3 + .../repositories/BookingRepository.test.ts | 4 +- .../repositories/BookingRepository.ts | 1 + .../getGuestBusyTimesForReschedule.test.ts | 156 ++++++++++++++++-- .../trpc/server/routers/viewer/slots/types.ts | 1 + .../trpc/server/routers/viewer/slots/util.ts | 21 ++- 7 files changed, 167 insertions(+), 21 deletions(-) diff --git a/apps/web/modules/schedules/hooks/useEvent.ts b/apps/web/modules/schedules/hooks/useEvent.ts index b91671ededf7d2..19c5688d62afe2 100644 --- a/apps/web/modules/schedules/hooks/useEvent.ts +++ b/apps/web/modules/schedules/hooks/useEvent.ts @@ -101,6 +101,7 @@ export const useScheduleForEvent = ({ const searchParams = useCompatSearchParams(); const rescheduleUid = searchParams?.get("rescheduleUid"); + const rescheduledBy = searchParams?.get("rescheduledBy"); const schedule = useSchedule({ username: usernameFromStore ?? username, @@ -110,6 +111,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 90a80dd4ba4831..39992e4a210cd9 100644 --- a/apps/web/modules/schedules/hooks/useSchedule.ts +++ b/apps/web/modules/schedules/hooks/useSchedule.ts @@ -20,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; @@ -58,6 +59,7 @@ export const useSchedule = ({ duration, dayCount, rescheduleUid, + rescheduledBy, isTeamEvent, orgSlug, teamMemberEmail, @@ -102,6 +104,7 @@ export const useSchedule = ({ timeZone: timezone ?? "PLACEHOLDER_TIMEZONE", duration: duration ? `${duration}` : undefined, rescheduleUid, + rescheduledBy, orgSlug, teamMemberEmail, routedTeamMemberIds, diff --git a/packages/features/bookings/repositories/BookingRepository.test.ts b/packages/features/bookings/repositories/BookingRepository.test.ts index 2dce2ebff09389..2ea7e582301ffb 100644 --- a/packages/features/bookings/repositories/BookingRepository.test.ts +++ b/packages/features/bookings/repositories/BookingRepository.test.ts @@ -69,11 +69,12 @@ describe("BookingRepository", () => { }); describe("findByUidIncludeAttendeeEmails", () => { - it("should query booking by uid with attendee emails", async () => { + 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); @@ -86,6 +87,7 @@ describe("BookingRepository", () => { id: true, uid: true, attendees: { select: { email: true } }, + user: { select: { email: true } }, }, }); }); diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index 9709c429c66a14..fca1e9d6289626 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -2145,6 +2145,7 @@ export class BookingRepository implements IBookingRepository { id: true, uid: true, attendees: { select: { email: true } }, + user: { select: { email: true } }, }, }); } diff --git a/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts index 34017e84d2b33b..b64f80aa350d94 100644 --- a/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts +++ b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts @@ -20,6 +20,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { 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(); @@ -39,6 +40,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const callGetGuestBusyTimes = (params: { rescheduleUid: string | null | undefined; + rescheduledBy?: string | null | undefined; schedulingType: SchedulingType | null; dateFrom: Date; dateTo: Date; @@ -90,12 +92,13 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, uid: rescheduleUid, - userId: 1, attendees: [], + user: { email: hostEmail }, }); const result = await callGetGuestBusyTimes({ rescheduleUid, + rescheduledBy: hostEmail, schedulingType: null, dateFrom, dateTo, @@ -123,13 +126,14 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, uid: rescheduleUid, - userId: 1, attendees: [{ email: "external@gmail.com" }], + user: { email: hostEmail }, }); mockDependencies.userRepo.findByEmails.mockResolvedValue([]); const result = await callGetGuestBusyTimes({ rescheduleUid, + rescheduledBy: hostEmail, schedulingType: null, dateFrom, dateTo, @@ -140,13 +144,137 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { }); }); + 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, - userId: 1, attendees: [{ email: "guest@cal.com" }], + user: { email: hostEmail }, }); mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([ @@ -162,6 +290,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, + rescheduledBy: hostEmail, schedulingType: null, dateFrom, dateTo, @@ -179,8 +308,8 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, uid: rescheduleUid, - userId: 1, attendees: [{ email: "guest@cal.com" }], + user: { email: hostEmail }, }); mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([ @@ -196,6 +325,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, + rescheduledBy: hostEmail, schedulingType: null, dateFrom, dateTo, @@ -230,13 +360,12 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, uid: rescheduleUid, - userId: 1, attendees: [ { email: "caluser@cal.com" }, { email: "external@gmail.com" }, ], + user: { email: hostEmail }, }); - // Only caluser@cal.com is a Cal.com user mockDependencies.userRepo.findByEmails.mockResolvedValue([ { id: 10, email: "caluser@cal.com" }, ]); @@ -244,18 +373,17 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { await callGetGuestBusyTimes({ rescheduleUid, + rescheduledBy: hostEmail, schedulingType: null, dateFrom, dateTo, }); - // userEmails should only contain the Cal.com user's email expect(mockDependencies.bookingRepo.findByUserIdsAndDateRange).toHaveBeenCalledWith( expect.objectContaining({ userEmails: ["caluser@cal.com"], }) ); - // Should NOT contain external@gmail.com const callArgs = mockDependencies.bookingRepo.findByUserIdsAndDateRange.mock.calls[0][0]; expect(callArgs.userEmails).not.toContain("external@gmail.com"); }); @@ -264,8 +392,8 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, uid: rescheduleUid, - userId: 1, attendees: [{ email: "guest1@cal.com" }, { email: "guest2@cal.com" }], + user: { email: hostEmail }, }); mockDependencies.userRepo.findByEmails.mockResolvedValue([ { id: 10, email: "guest1@cal.com" }, @@ -292,6 +420,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { const result = await callGetGuestBusyTimes({ rescheduleUid, + rescheduledBy: hostEmail, schedulingType: null, dateFrom, dateTo, @@ -306,6 +435,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { userEmails: ["guest1@cal.com", "guest2@cal.com"], dateFrom, dateTo, + excludeUid: rescheduleUid, }); }); @@ -313,14 +443,15 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, uid: rescheduleUid, - userId: 1, 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, @@ -329,7 +460,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { expect(result).toEqual([]); expect(mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails).toHaveBeenCalledWith({ uid: rescheduleUid, - userId: 1, }); }); @@ -337,8 +467,8 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, uid: rescheduleUid, - userId: 1, attendees: [{ email: "cal-user@example.com" }, { email: "external@gmail.com" }], + user: { email: hostEmail }, }); mockDependencies.userRepo.findByEmails.mockResolvedValue([ { id: 42, email: "cal-user@example.com" }, @@ -347,6 +477,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { await callGetGuestBusyTimes({ rescheduleUid, + rescheduledBy: hostEmail, schedulingType: null, dateFrom, dateTo, @@ -355,7 +486,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { expect(mockDependencies.userRepo.findByEmails).toHaveBeenCalledWith({ emails: ["cal-user@example.com", "external@gmail.com"], }); - // userEmails should only contain Cal.com user emails, not external ones expect(mockDependencies.bookingRepo.findByUserIdsAndDateRange).toHaveBeenCalledWith({ userIds: [42], userEmails: ["cal-user@example.com"], diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index 58631316ea16c3..bd5f2f75003442 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -26,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 7152f9ea181f3f..d77600f80ef1bd 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -658,11 +658,13 @@ export class AvailableSlotsService { */ private async _getGuestBusyTimesForReschedule({ rescheduleUid, + rescheduledBy, schedulingType, dateFrom, dateTo, }: { rescheduleUid: string | null | undefined; + rescheduledBy: string | null | undefined; schedulingType: SchedulingType | null; dateFrom: Date; dateTo: Date; @@ -677,13 +679,17 @@ export class AvailableSlotsService { }); if (!original?.attendees?.length) return []; - // Note: The slots API does not receive `rescheduledBy` context, so we - // cannot distinguish host-initiated from attendee-initiated reschedules - // at this layer. We always check guest availability as the safe default: - // showing fewer available slots is preferable to risking double-bookings. - // Per CarinaWolli's spec, attendee reschedules should show all slots — - // if this gating is needed, `rescheduledBy` must be added to the slots - // input schema (a separate change). + // 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) @@ -801,6 +807,7 @@ export class AvailableSlotsService { this.getOOODates(startTimeDate, endTimeDate, allUserIds), this.getGuestBusyTimesForReschedule({ rescheduleUid: input.rescheduleUid, + rescheduledBy: input.rescheduledBy, schedulingType: eventType.schedulingType, dateFrom: startTimeDate, dateTo: endTimeDate, From 50c4055086b2e3e713dd2be84a6937cb4b507853 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Sun, 29 Mar 2026 19:56:08 +0700 Subject: [PATCH 12/29] fix: use empty string instead of undefined for EventBusyDetails source The source field on EventBusyDetails is typed as string (not optional). Using undefined when withSource is false violates the type contract. Use empty string as the fallback to maintain type safety. --- packages/features/availability/lib/getUserAvailability.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/availability/lib/getUserAvailability.ts b/packages/features/availability/lib/getUserAvailability.ts index 4aa7588b06b77a..7fdb8b7fc38482 100644 --- a/packages/features/availability/lib/getUserAvailability.ts +++ b/packages/features/availability/lib/getUserAvailability.ts @@ -623,7 +623,7 @@ export class UserAvailabilityService { start: dayjs.utc(t.start).toISOString(), end: dayjs.utc(t.end).toISOString(), title: "Guest busy", - source: withSource ? "guest-availability" : undefined, + source: withSource ? "guest-availability" : "", }) ); From f8174f615d1dab8d56918c3fa69a299d4e664499 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Sun, 29 Mar 2026 14:15:13 +0000 Subject: [PATCH 13/29] fix: resolve TS2802 Set iteration error and apply biome formatting Replace `[...new Set()]` with `Array.from(new Set())` in UserRepository.findByEmails to fix TypeScript downlevelIteration compilation error. Also applies biome auto-formatting (import ordering, line wrapping) across changed files. https://claude.ai/code/session_01P7vSb25vbhtTxChmTMQsew --- apps/web/modules/schedules/hooks/useEvent.ts | 1 + .../availability/lib/getUserAvailability.ts | 14 ++++++-------- .../users/repositories/UserRepository.test.ts | 1 + .../features/users/repositories/UserRepository.ts | 2 +- .../slots/getGuestBusyTimesForReschedule.test.ts | 13 +++---------- packages/trpc/server/routers/viewer/slots/util.ts | 10 ++++------ 6 files changed, 16 insertions(+), 25 deletions(-) diff --git a/apps/web/modules/schedules/hooks/useEvent.ts b/apps/web/modules/schedules/hooks/useEvent.ts index 19c5688d62afe2..c0ae338031a86d 100644 --- a/apps/web/modules/schedules/hooks/useEvent.ts +++ b/apps/web/modules/schedules/hooks/useEvent.ts @@ -6,6 +6,7 @@ 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"; export type useEventReturnType = ReturnType; export type useScheduleForEventReturnType = ReturnType; diff --git a/packages/features/availability/lib/getUserAvailability.ts b/packages/features/availability/lib/getUserAvailability.ts index 7fdb8b7fc38482..83cc2cb775362d 100644 --- a/packages/features/availability/lib/getUserAvailability.ts +++ b/packages/features/availability/lib/getUserAvailability.ts @@ -618,14 +618,12 @@ 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 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) => ({ diff --git a/packages/features/users/repositories/UserRepository.test.ts b/packages/features/users/repositories/UserRepository.test.ts index 10505ea52eefff..502bc8e875fc5b 100644 --- a/packages/features/users/repositories/UserRepository.test.ts +++ b/packages/features/users/repositories/UserRepository.test.ts @@ -3,6 +3,7 @@ import { UserRepository } from "@calcom/features/users/repositories/UserReposito 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(), diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index 863937267c9b6f..1b695a2a937ed5 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -1510,7 +1510,7 @@ export class UserRepository { async findByEmails({ emails }: { emails: string[] }) { if (!emails.length) return []; - const normalized = [...new Set(emails.map((e) => e.toLowerCase()))]; + const normalized = Array.from(new Set(emails.map((e) => e.toLowerCase()))); const [byPrimary, bySecondary] = await Promise.all([ this.prismaClient.user.findMany({ diff --git a/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts index b64f80aa350d94..71b5d9f8f1b150 100644 --- a/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts +++ b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts @@ -360,15 +360,10 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ id: 1, uid: rescheduleUid, - attendees: [ - { email: "caluser@cal.com" }, - { email: "external@gmail.com" }, - ], + attendees: [{ email: "caluser@cal.com" }, { email: "external@gmail.com" }], user: { email: hostEmail }, }); - mockDependencies.userRepo.findByEmails.mockResolvedValue([ - { id: 10, email: "caluser@cal.com" }, - ]); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "caluser@cal.com" }]); mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); await callGetGuestBusyTimes({ @@ -470,9 +465,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { 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.userRepo.findByEmails.mockResolvedValue([{ id: 42, email: "cal-user@example.com" }]); mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); await callGetGuestBusyTimes({ diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index d77600f80ef1bd..bf547afe39e20d 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -40,6 +40,7 @@ import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents"; import type { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; 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"; @@ -66,7 +67,7 @@ import { import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { withReporting } from "@calcom/lib/sentryWrapper"; -import { PeriodType } from "@calcom/prisma/enums"; +import { PeriodType, SchedulingType } from "@calcom/prisma/enums"; import type { CalendarFetchMode, EventBusyDate, EventBusyDetails } from "@calcom/types/Calendar"; import type { CredentialForCalendarService } from "@calcom/types/Credential"; import { TRPCError } from "@trpc/server"; @@ -684,16 +685,13 @@ export class AvailableSlotsService { // without being constrained by other guests' schedules. if (rescheduledBy) { const hostEmail = original.user?.email; - const isHostReschedule = - hostEmail && rescheduledBy.toLowerCase() === hostEmail.toLowerCase(); + 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)); + 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 }); From 13edf26889b835318bf2b8b3ddc0cb5b1149ab6c Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Wed, 1 Apr 2026 15:42:16 +0000 Subject: [PATCH 14/29] chore: trigger CLA recheck From 5e327e8f7d2c3d117452fb7a28524b0fb33aa518 Mon Sep 17 00:00:00 2001 From: Bcornish Date: Wed, 15 Apr 2026 18:53:02 +0700 Subject: [PATCH 15/29] feat(slots): add observability to guest-busy-times degradation path The catch arm in _getGuestBusyTimesForReschedule silently returned [] on any failure to keep rescheduling unblocked. That is the right runtime behaviour, but a silent swallow makes upstream regressions (e.g. a Prisma schema drift in BookingRepository.findByUidIncludeAttendeeEmails) look like 'no Cal.com guests found' rather than a real fault. Emit a structured warn through the existing slots/util logger so operators can detect this without paging on a non-blocking code path. Uses safeStringify (already imported and used elsewhere in this file) so the error never breaks the log line. --- packages/trpc/server/routers/viewer/slots/util.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index bf547afe39e20d..3a82e6a8dce067 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -711,7 +711,13 @@ export class AvailableSlotsService { return guestBookings.map((b) => ({ start: b.startTime, end: b.endTime })); } catch (error) { - // Graceful degradation: never block rescheduling if guest lookup fails + // 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 []; } } From 69733d3e317cb28fd6483941fd08f697d39ef63c Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Tue, 21 Apr 2026 20:11:58 +0700 Subject: [PATCH 16/29] fix(availability): drop Day.js round-trip in guestBusyTimesFormatted guestBusyTimes is typed as Date, so native Date.toISOString() is sufficient. Addresses CodeRabbit nitpick on PR #28636. --- packages/features/availability/lib/getUserAvailability.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/features/availability/lib/getUserAvailability.ts b/packages/features/availability/lib/getUserAvailability.ts index 83cc2cb775362d..e98319533a37d0 100644 --- a/packages/features/availability/lib/getUserAvailability.ts +++ b/packages/features/availability/lib/getUserAvailability.ts @@ -619,8 +619,8 @@ export class UserAvailabilityService { } const guestBusyTimesFormatted: EventBusyDetails[] = (initialData?.guestBusyTimes ?? []).map((t) => ({ - start: dayjs.utc(t.start).toISOString(), - end: dayjs.utc(t.end).toISOString(), + start: t.start.toISOString(), + end: t.end.toISOString(), title: "Guest busy", source: withSource ? "guest-availability" : "", })); From fb8da3c520146f2162b88851f77a846854a8264a Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Tue, 21 Apr 2026 20:16:34 +0700 Subject: [PATCH 17/29] fix(useEvent): restore restrictionSchedule/useStableTimezone wiring Lost during rebase conflict resolution. Re-integrates useStableTimezone in useScheduleForEvent via the restrictionSchedule param. --- apps/web/modules/schedules/hooks/useEvent.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/web/modules/schedules/hooks/useEvent.ts b/apps/web/modules/schedules/hooks/useEvent.ts index c0ae338031a86d..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; @@ -72,6 +70,7 @@ export const useScheduleForEvent = ({ isTeamEvent, useApiV2 = true, bookerLayout, + restrictionSchedule, }: { username?: string | null; eventSlug?: string | null; @@ -93,13 +92,16 @@ export const useScheduleForEvent = ({ extraDays: number; columnViewExtraDays: { current: number }; }; + restrictionSchedule?: { id: number | null; useBookerTimezone: boolean }; }) => { - const { timezone } = useBookerTime(); + const { timezone: rawTimezone } = useBookerTime(); const [usernameFromStore, eventSlugFromStore, monthFromStore, durationFromStore] = useBookerStoreContext( (state) => [state.username, state.eventSlug, state.month, state.selectedDuration], shallow ); + const effectiveTimezone = useStableTimezone(rawTimezone, restrictionSchedule); + const searchParams = useCompatSearchParams(); const rescheduleUid = searchParams?.get("rescheduleUid"); const rescheduledBy = searchParams?.get("rescheduledBy"); @@ -108,7 +110,7 @@ export const useScheduleForEvent = ({ username: usernameFromStore ?? username, eventSlug: eventSlugFromStore ?? eventSlug, eventId, - timezone, + timezone: effectiveTimezone, selectedDate, dayCount, rescheduleUid, From 989b4197bc4cec6f7e758fe5420b8046ae7b190a Mon Sep 17 00:00:00 2001 From: Sochanged <42561104+sochanged@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:08:04 +0700 Subject: [PATCH 18/29] fix(users): findByEmails returns matchedEmails (primary and verified secondary) Secondary-email hits were collapsing to the primary address, so guest busy-time lookup missed bookings where a Cal.com user attended under a verified secondary email. findByEmails now returns matchedEmails: string[] per user (union of primary and/or secondary matches), and the guest-busy flow feeds those actual matched emails into findByUserIdsAndDateRange. --- .../users/repositories/UserRepository.test.ts | 29 +++++++++++++++---- .../users/repositories/UserRepository.ts | 27 ++++++++++++++--- .../trpc/server/routers/viewer/slots/util.ts | 6 ++-- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/packages/features/users/repositories/UserRepository.test.ts b/packages/features/users/repositories/UserRepository.test.ts index 502bc8e875fc5b..d663dc19305538 100644 --- a/packages/features/users/repositories/UserRepository.test.ts +++ b/packages/features/users/repositories/UserRepository.test.ts @@ -145,18 +145,28 @@ describe("UserRepository", () => { const result = await repo.findByEmails({ emails: ["user@example.com"] }); - expect(result).toEqual([{ id: 1, email: "user@example.com" }]); + expect(result).toEqual([ + { id: 1, email: "user@example.com", matchedEmails: ["user@example.com"] }, + ]); expect(mockPrismaClient.user.findMany).toHaveBeenCalledTimes(2); }); - test("should look up users by secondary (verified) email", async () => { + test("should look up users by secondary (verified) email and return the matched address", async () => { mockPrismaClient.user.findMany .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ id: 2, email: "primary@example.com" }]); + .mockResolvedValueOnce([ + { + id: 2, + email: "primary@example.com", + secondaryEmails: [{ email: "secondary@example.com" }], + }, + ]); const result = await repo.findByEmails({ emails: ["secondary@example.com"] }); - expect(result).toEqual([{ id: 2, email: "primary@example.com" }]); + expect(result).toEqual([ + { id: 2, email: "primary@example.com", matchedEmails: ["secondary@example.com"] }, + ]); expect(mockPrismaClient.user.findMany).toHaveBeenNthCalledWith( 2, expect.objectContaining({ @@ -172,15 +182,22 @@ describe("UserRepository", () => { ); }); - test("should deduplicate users found via both primary and secondary email", async () => { + test("should union primary and secondary matches for the same user", async () => { mockPrismaClient.user.findMany .mockResolvedValueOnce([{ id: 1, email: "user@example.com" }]) - .mockResolvedValueOnce([{ id: 1, email: "user@example.com" }]); + .mockResolvedValueOnce([ + { + id: 1, + email: "user@example.com", + secondaryEmails: [{ email: "alias@example.com" }], + }, + ]); const result = await repo.findByEmails({ emails: ["user@example.com", "alias@example.com"] }); expect(result).toHaveLength(1); expect(result[0].id).toBe(1); + expect(result[0].matchedEmails.sort()).toEqual(["alias@example.com", "user@example.com"]); }); test("should normalize emails to lowercase and deduplicate input", async () => { diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index 1b695a2a937ed5..2e191958466242 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -1526,13 +1526,32 @@ export class UserRepository { }, }, }, - select: { id: true, email: true }, + select: { + id: true, + email: true, + secondaryEmails: { + where: { + email: { in: normalized, mode: "insensitive" }, + emailVerified: { not: null }, + }, + select: { email: true }, + }, + }, }), ]); - const seen = new Map(); - for (const u of [...byPrimary, ...bySecondary]) { - if (!seen.has(u.id)) seen.set(u.id, u); + const seen = new Map(); + for (const u of byPrimary) { + seen.set(u.id, { id: u.id, email: u.email, matchedEmails: [u.email] }); + } + for (const u of bySecondary) { + const existing = seen.get(u.id); + const secondaryMatches = u.secondaryEmails.map((s) => s.email); + if (existing) { + existing.matchedEmails = Array.from(new Set([...existing.matchedEmails, ...secondaryMatches])); + } else { + seen.set(u.id, { id: u.id, email: u.email, matchedEmails: secondaryMatches }); + } } return Array.from(seen.values()); } diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 3a82e6a8dce067..caa27c64e4a047 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -697,9 +697,9 @@ export class AvailableSlotsService { 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); + // Use the actual matched emails (primary and/or verified secondary) so bookings + // where a Cal.com user participates under a secondary address are still caught. + const calUserEmails = Array.from(new Set(calUsers.flatMap((u) => u.matchedEmails))); const guestBookings = await this.dependencies.bookingRepo.findByUserIdsAndDateRange({ userIds: calUsers.map((u) => u.id), From 23b3609392fb9178b2071a1cbfb14d6766efb9d5 Mon Sep 17 00:00:00 2001 From: Sochanged <42561104+sochanged@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:08:18 +0700 Subject: [PATCH 19/29] fix(schedule): fall back to V1 slots when rescheduledBy is set The API V2 available-slots DTO does not carry rescheduledBy, so routing a reschedule through V2 loses the host/attendee initiator context that the V1 guest busy-time gate relies on. The useApiV2Slots guard now opts back to V1 whenever both rescheduleUid and rescheduledBy are present. --- apps/web/modules/schedules/hooks/useSchedule.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/modules/schedules/hooks/useSchedule.ts b/apps/web/modules/schedules/hooks/useSchedule.ts index 39992e4a210cd9..37ef29570f3add 100644 --- a/apps/web/modules/schedules/hooks/useSchedule.ts +++ b/apps/web/modules/schedules/hooks/useSchedule.ts @@ -138,7 +138,11 @@ export const useSchedule = ({ enabledProp, }; - const isCallingApiV2Slots = useApiV2 && Boolean(isTeamEvent) && options.enabled; + // Fall back to V1 when rescheduledBy is set: the V2 available-slots DTO does not + // carry rescheduledBy, so host/attendee initiator gating in guest busy-time lookup + // would be lost if we routed the reschedule through V2. + const isCallingApiV2Slots = + useApiV2 && Boolean(isTeamEvent) && options.enabled && !(rescheduleUid && rescheduledBy); // API V2 query for team events const teamScheduleV2 = useApiV2AvailableSlots({ From 5ca694d295e7717a94884cb01b3538d227fc304c Mon Sep 17 00:00:00 2001 From: Sochanged <42561104+sochanged@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:08:49 +0700 Subject: [PATCH 20/29] chore(tests): drop comments that restate their assertions Three inline comments in getGuestBusyTimesForReschedule.test.ts just paraphrased the expect lines below them. Removed per the project coding guideline that discourages comments which only restate what the code does. --- .../getGuestBusyTimesForReschedule.test.ts | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts index 71b5d9f8f1b150..72af4926b67216 100644 --- a/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts +++ b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts @@ -173,7 +173,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { attendees: [{ email: "guest@cal.com" }], user: { email: hostEmail }, }); - mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com", matchedEmails: ["guest@cal.com"] }]); mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([ { uid: "other-booking-1", @@ -208,7 +208,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { attendees: [{ email: "guest@cal.com" }], user: { email: "Host@Cal.COM" }, }); - mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com", matchedEmails: ["guest@cal.com"] }]); mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); await callGetGuestBusyTimes({ @@ -219,7 +219,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { dateTo, }); - // Should proceed to check guest availability (host email matches case-insensitively) expect(mockDependencies.userRepo.findByEmails).toHaveBeenCalled(); }); @@ -230,7 +229,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { attendees: [{ email: "guest@cal.com" }], user: { email: hostEmail }, }); - mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com", matchedEmails: ["guest@cal.com"] }]); mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); await callGetGuestBusyTimes({ @@ -241,7 +240,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { dateTo, }); - // Without rescheduledBy, should still check guest availability (safe default) expect(mockDependencies.userRepo.findByEmails).toHaveBeenCalled(); }); @@ -252,7 +250,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { attendees: [{ email: "guest@cal.com" }], user: { email: hostEmail }, }); - mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com", matchedEmails: ["guest@cal.com"] }]); mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); await callGetGuestBusyTimes({ @@ -263,7 +261,6 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { dateTo, }); - // Without rescheduledBy, should still check guest availability (safe default) expect(mockDependencies.userRepo.findByEmails).toHaveBeenCalled(); }); }); @@ -276,7 +273,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { attendees: [{ email: "guest@cal.com" }], user: { email: hostEmail }, }); - mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com", matchedEmails: ["guest@cal.com"] }]); mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([ { uid: "other-booking-1", @@ -311,7 +308,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { attendees: [{ email: "guest@cal.com" }], user: { email: hostEmail }, }); - mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com", matchedEmails: ["guest@cal.com"] }]); mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([ { uid: "different-booking", @@ -363,7 +360,9 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { attendees: [{ email: "caluser@cal.com" }, { email: "external@gmail.com" }], user: { email: hostEmail }, }); - mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "caluser@cal.com" }]); + mockDependencies.userRepo.findByEmails.mockResolvedValue([ + { id: 10, email: "caluser@cal.com", matchedEmails: ["caluser@cal.com"] }, + ]); mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); await callGetGuestBusyTimes({ @@ -391,8 +390,8 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { user: { email: hostEmail }, }); mockDependencies.userRepo.findByEmails.mockResolvedValue([ - { id: 10, email: "guest1@cal.com" }, - { id: 20, email: "guest2@cal.com" }, + { id: 10, email: "guest1@cal.com", matchedEmails: ["guest1@cal.com"] }, + { id: 20, email: "guest2@cal.com", matchedEmails: ["guest2@cal.com"] }, ]); mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([ { @@ -441,7 +440,7 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { attendees: [{ email: "guest@cal.com" }], user: { email: hostEmail }, }); - mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com" }]); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "guest@cal.com", matchedEmails: ["guest@cal.com"] }]); mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); const result = await callGetGuestBusyTimes({ @@ -465,7 +464,9 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { 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.userRepo.findByEmails.mockResolvedValue([ + { id: 42, email: "cal-user@example.com", matchedEmails: ["cal-user@example.com"] }, + ]); mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); await callGetGuestBusyTimes({ From efb6cbcbf7b8a50d6196ee17e978bb875a0beb51 Mon Sep 17 00:00:00 2001 From: Sochanged <42561104+sochanged@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:48:38 +0700 Subject: [PATCH 21/29] test(users): tighten findByEmails assertions Add missing assertion on the bySecondary findMany call in the normalization test, and assert the exact { id, email, matchedEmails } shape in the multi-user test so regressions in the matchedEmails mapping fail loudly. --- .../users/repositories/UserRepository.test.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/features/users/repositories/UserRepository.test.ts b/packages/features/users/repositories/UserRepository.test.ts index d663dc19305538..c8d2f29d42694a 100644 --- a/packages/features/users/repositories/UserRepository.test.ts +++ b/packages/features/users/repositories/UserRepository.test.ts @@ -213,9 +213,22 @@ describe("UserRepository", () => { where: { email: { in: ["user@example.com"], mode: "insensitive" } }, }) ); + expect(mockPrismaClient.user.findMany).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + where: { + secondaryEmails: { + some: { + email: { in: ["user@example.com"], mode: "insensitive" }, + emailVerified: { not: null }, + }, + }, + }, + }) + ); }); - test("should return multiple distinct users", async () => { + test("should return multiple distinct users with their matched emails", async () => { mockPrismaClient.user.findMany .mockResolvedValueOnce([ { id: 1, email: "user1@example.com" }, @@ -228,6 +241,12 @@ describe("UserRepository", () => { }); expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + { id: 1, email: "user1@example.com", matchedEmails: ["user1@example.com"] }, + { id: 2, email: "user2@example.com", matchedEmails: ["user2@example.com"] }, + ]) + ); }); }); }); From c0504647f1c6db336f227aa2b6b53fd6821c8ad6 Mon Sep 17 00:00:00 2001 From: Sochanged <42561104+sochanged@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:48:38 +0700 Subject: [PATCH 22/29] fix(slots): bound degradation log payload in guest busy-time fallback Replace safeStringify({ rescheduleUid, error }) with a structured, size-bounded object that surfaces Error.name/message/code and falls back to safeStringify only for non-Error values. Avoids unbounded nested repository error payloads and dropped Error fields. --- .../trpc/server/routers/viewer/slots/util.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index caa27c64e4a047..deb1aa0d82df18 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -713,11 +713,20 @@ export class AvailableSlotsService { } 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 }) - ); + // without paging on a non-blocking code path. Structured payload keeps + // the log bounded — no stack traces or large nested repository errors. + const errorDetails = + error instanceof Error + ? { + name: error.name, + message: error.message, + code: "code" in error ? String((error as { code?: unknown }).code) : undefined, + } + : { value: safeStringify(error) }; + log.warn("[getGuestBusyTimesForReschedule] degraded to empty result", { + rescheduleUid, + error: errorDetails, + }); return []; } } From 0a83d1a7e8f5c874413994778cb36c6522028bf2 Mon Sep 17 00:00:00 2001 From: Sochanged <42561104+sochanged@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:48:28 +0700 Subject: [PATCH 23/29] fix(bookings): dedupe findByUserIdsAndDateRange results by uid The OR between userId and attendees.email can surface the same booking twice when a guest is both organizer and attendee on that row. Downstream subtract() in getUserAvailability is idempotent, so correctness holds, but payloads were inflated and busy-time diagnostics misleading. Dedupe by uid in-memory before returning. --- .../bookings/repositories/BookingRepository.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index fca1e9d6289626..2f48a5205debee 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -2165,7 +2165,7 @@ export class BookingRepository implements IBookingRepository { }) { if (!userIds.length && !userEmails.length) return []; - return this.prismaClient.booking.findMany({ + const rows = await this.prismaClient.booking.findMany({ where: { status: { in: [BookingStatus.ACCEPTED, BookingStatus.PENDING] }, AND: [{ startTime: { lt: dateTo } }, { endTime: { gt: dateFrom } }], @@ -2186,5 +2186,13 @@ export class BookingRepository implements IBookingRepository { status: true, }, }); + + // Dedupe by uid: the OR between userId and attendees.email can match the same + // booking twice (e.g., a guest who is both organizer and listed among attendees). + const unique = new Map(); + for (const row of rows) { + if (!unique.has(row.uid)) unique.set(row.uid, row); + } + return Array.from(unique.values()); } } From e8a7275e02380deb16e4ddb8703987c9e4218a16 Mon Sep 17 00:00:00 2001 From: Sochanged <42561104+sochanged@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:48:29 +0700 Subject: [PATCH 24/29] chore(slots): address round-3 review nits - util.ts: guard null/undefined error.code so the log does not emit literal code: "undefined" / "null" - types.ts: validate rescheduledBy as z.string().email().nullish() to reject malformed inputs early - getUserAvailability.ts: omit source instead of setting source: "" when withSource is false, matches the Optional typing - useSchedule.ts: TODO breadcrumb for the V2 DTO follow-up so the fallback site is easy to locate later --- apps/web/modules/schedules/hooks/useSchedule.ts | 3 +++ packages/features/availability/lib/getUserAvailability.ts | 2 +- packages/trpc/server/routers/viewer/slots/types.ts | 2 +- packages/trpc/server/routers/viewer/slots/util.ts | 3 ++- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/modules/schedules/hooks/useSchedule.ts b/apps/web/modules/schedules/hooks/useSchedule.ts index 37ef29570f3add..7788d8b2ecdc10 100644 --- a/apps/web/modules/schedules/hooks/useSchedule.ts +++ b/apps/web/modules/schedules/hooks/useSchedule.ts @@ -141,6 +141,9 @@ export const useSchedule = ({ // Fall back to V1 when rescheduledBy is set: the V2 available-slots DTO does not // carry rescheduledBy, so host/attendee initiator gating in guest busy-time lookup // would be lost if we routed the reschedule through V2. + // TODO: extend the V2 available-slots DTO to accept rescheduledBy and drop this + // fallback. Requires propagating the param through the slots service and adding + // host-initiated reschedule test coverage. const isCallingApiV2Slots = useApiV2 && Boolean(isTeamEvent) && options.enabled && !(rescheduleUid && rescheduledBy); diff --git a/packages/features/availability/lib/getUserAvailability.ts b/packages/features/availability/lib/getUserAvailability.ts index e98319533a37d0..5cfd7e64f6fbc5 100644 --- a/packages/features/availability/lib/getUserAvailability.ts +++ b/packages/features/availability/lib/getUserAvailability.ts @@ -622,7 +622,7 @@ export class UserAvailabilityService { start: t.start.toISOString(), end: t.end.toISOString(), title: "Guest busy", - source: withSource ? "guest-availability" : "", + ...(withSource ? { source: "guest-availability" } : {}), })); const detailedBusyTimesWithSource: EventBusyDetails[] = [ diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index bd5f2f75003442..6928cbf4c76619 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -26,7 +26,7 @@ export const getScheduleSchemaObject = z.object({ .optional() .transform((val) => val && parseInt(val)), rescheduleUid: z.string().nullish(), - rescheduledBy: z.string().nullish(), + rescheduledBy: z.string().email().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 deb1aa0d82df18..03b83424e1009a 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -715,12 +715,13 @@ export class AvailableSlotsService { // Log at warn (not error) so operators can detect upstream regressions // without paging on a non-blocking code path. Structured payload keeps // the log bounded — no stack traces or large nested repository errors. + const codeVal = error instanceof Error ? (error as { code?: unknown }).code : undefined; const errorDetails = error instanceof Error ? { name: error.name, message: error.message, - code: "code" in error ? String((error as { code?: unknown }).code) : undefined, + ...(codeVal != null ? { code: String(codeVal) } : {}), } : { value: safeStringify(error) }; log.warn("[getGuestBusyTimesForReschedule] degraded to empty result", { From e5ee2dc0316dc4ee15c80ce6c6d1bccc5cd9ffb3 Mon Sep 17 00:00:00 2001 From: Sochanged <42561104+sochanged@users.noreply.github.com> Date: Wed, 22 Apr 2026 01:21:23 +0700 Subject: [PATCH 25/29] fix(slots): drop stale RoutingFormResponseRepository import and fix guestBusyTimesFormatted typing Two regressions surfaced by local type-check: - util.ts imported a now-removed RoutingFormResponseRepository type; the field that referenced it was refactored out of IAvailableSlotsService in upstream but the import lingered in the rebased branch (TS2307). - guestBusyTimesFormatted used a conditional spread for source which made the object shape incompatible with EventBusyDetails's required source. Cast the mapped array to EventBusyDetails[] so the conditional spread is accepted (TS2322). --- packages/features/availability/lib/getUserAvailability.ts | 4 ++-- packages/trpc/server/routers/viewer/slots/util.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/features/availability/lib/getUserAvailability.ts b/packages/features/availability/lib/getUserAvailability.ts index 5cfd7e64f6fbc5..e9d50bd2fba1d9 100644 --- a/packages/features/availability/lib/getUserAvailability.ts +++ b/packages/features/availability/lib/getUserAvailability.ts @@ -618,12 +618,12 @@ export class UserAvailabilityService { }; } - const guestBusyTimesFormatted: EventBusyDetails[] = (initialData?.guestBusyTimes ?? []).map((t) => ({ + const guestBusyTimesFormatted = (initialData?.guestBusyTimes ?? []).map((t) => ({ start: t.start.toISOString(), end: t.end.toISOString(), title: "Guest busy", ...(withSource ? { source: "guest-availability" } : {}), - })); + })) as EventBusyDetails[]; const detailedBusyTimesWithSource: EventBusyDetails[] = [ ...busyTimes.map((a) => ({ diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 03b83424e1009a..711a4a64d5153f 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -40,7 +40,6 @@ import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents"; import type { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; 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"; From 45899ba74d6d247e9203e50ed87207a7dadb0f14 Mon Sep 17 00:00:00 2001 From: Sochanged <42561104+sochanged@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:50:58 +0700 Subject: [PATCH 26/29] fix(availability): always set source on guestBusyTimesFormatted entries Revert the conditional-spread approach and always set source to guest-availability. The existing strip step in detailedBusyTimes already drops source when withSource is false, and this keeps the array shape consistent with the sibling busyTimes/busyTimesFromLimits/busyTimesFromTeamLimits arrays. --- packages/features/availability/lib/getUserAvailability.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/features/availability/lib/getUserAvailability.ts b/packages/features/availability/lib/getUserAvailability.ts index e9d50bd2fba1d9..9b2ff2d078c376 100644 --- a/packages/features/availability/lib/getUserAvailability.ts +++ b/packages/features/availability/lib/getUserAvailability.ts @@ -618,12 +618,12 @@ export class UserAvailabilityService { }; } - const guestBusyTimesFormatted = (initialData?.guestBusyTimes ?? []).map((t) => ({ + const guestBusyTimesFormatted: EventBusyDetails[] = (initialData?.guestBusyTimes ?? []).map((t) => ({ start: t.start.toISOString(), end: t.end.toISOString(), title: "Guest busy", - ...(withSource ? { source: "guest-availability" } : {}), - })) as EventBusyDetails[]; + source: "guest-availability", + })); const detailedBusyTimesWithSource: EventBusyDetails[] = [ ...busyTimes.map((a) => ({ From 37f58e217d9b3b9d10a71d62986a485aaf3919aa Mon Sep 17 00:00:00 2001 From: Sochanged <42561104+sochanged@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:51:05 +0700 Subject: [PATCH 27/29] fix(bookings): skip excludeUid filter on empty string + lock in dedup-by-uid - findByUserIdsAndDateRange: use excludeUid != null && excludeUid !== "" so an accidental empty string no longer silently includes the self-booking - Two new tests: assert empty excludeUid is ignored, and assert the OR/uid dedup collapses identical rows to one result --- .../repositories/BookingRepository.test.ts | 46 +++++++++++++++++++ .../repositories/BookingRepository.ts | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/features/bookings/repositories/BookingRepository.test.ts b/packages/features/bookings/repositories/BookingRepository.test.ts index 2ea7e582301ffb..17ba64b828572e 100644 --- a/packages/features/bookings/repositories/BookingRepository.test.ts +++ b/packages/features/bookings/repositories/BookingRepository.test.ts @@ -239,5 +239,51 @@ describe("BookingRepository", () => { }) ); }); + + it("should skip excludeUid filter when empty string is passed", async () => { + mockPrismaClient.booking.findMany.mockResolvedValue([]); + + await repository.findByUserIdsAndDateRange({ + userIds: [1], + userEmails: [], + dateFrom, + dateTo, + excludeUid: "", + }); + + const callArgs = mockPrismaClient.booking.findMany.mock.calls[0][0]; + expect(callArgs.where).not.toHaveProperty("uid"); + }); + + it("should dedupe by uid when OR branches surface the same booking twice", async () => { + mockPrismaClient.booking.findMany.mockResolvedValue([ + { + uid: "booking-a", + startTime: new Date("2026-04-10T09:00:00Z"), + endTime: new Date("2026-04-10T10:00:00Z"), + title: "Team sync", + userId: 10, + status: "ACCEPTED", + }, + { + uid: "booking-a", + startTime: new Date("2026-04-10T09:00:00Z"), + endTime: new Date("2026-04-10T10:00:00Z"), + title: "Team sync", + userId: 10, + status: "ACCEPTED", + }, + ]); + + const result = await repository.findByUserIdsAndDateRange({ + userIds: [10], + userEmails: ["guest@example.com"], + dateFrom, + dateTo, + }); + + expect(result).toHaveLength(1); + expect(result[0].uid).toBe("booking-a"); + }); }); }); diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index 2f48a5205debee..0d1a5f268008a9 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -2175,7 +2175,7 @@ export class BookingRepository implements IBookingRepository { ? [{ attendees: { some: { email: { in: userEmails, mode: "insensitive" as const } } } }] : []), ], - ...(excludeUid ? { uid: { not: excludeUid } } : {}), + ...(excludeUid != null && excludeUid !== "" ? { uid: { not: excludeUid } } : {}), }, select: { uid: true, From 7cec9fb1c087eaea2dfb9ca446cad2f1d64b13ce Mon Sep 17 00:00:00 2001 From: Sochanged <42561104+sochanged@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:51:13 +0700 Subject: [PATCH 28/29] fix(schedule): normalize rescheduledBy URL param client-side Server zod now rejects non-email values for rescheduledBy; drop empty / at-less URL strings to null client-side so stale or malformed URLs do not break the schedule query. --- apps/web/modules/schedules/hooks/useEvent.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/modules/schedules/hooks/useEvent.ts b/apps/web/modules/schedules/hooks/useEvent.ts index 1db84ec2dd3889..223b3ffb0b681a 100644 --- a/apps/web/modules/schedules/hooks/useEvent.ts +++ b/apps/web/modules/schedules/hooks/useEvent.ts @@ -104,7 +104,12 @@ export const useScheduleForEvent = ({ const searchParams = useCompatSearchParams(); const rescheduleUid = searchParams?.get("rescheduleUid"); - const rescheduledBy = searchParams?.get("rescheduledBy"); + const rawRescheduledBy = searchParams?.get("rescheduledBy"); + // Lightweight client-side normalization: the server validates rescheduledBy + // as a non-empty email, so drop obviously malformed URL values (empty string, + // missing "@") to null before the query fires. + const rescheduledBy = + rawRescheduledBy && rawRescheduledBy.includes("@") ? rawRescheduledBy : null; const schedule = useSchedule({ username: usernameFromStore ?? username, From 577cb1738e7de4abc5267862daf05b3e20b93645 Mon Sep 17 00:00:00 2001 From: Sochanged <42561104+sochanged@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:51:20 +0700 Subject: [PATCH 29/29] docs+test(slots): document attendee-default gating + tighten host-branch assertion - util.ts: explicit comment on the gating semantics (default-safe-for-attendee when the initiator cannot be positively identified as host) - test: assert findByUserIdsAndDateRange was invoked in the case-insensitive host test, so an accidental early-return would fail loudly --- .../viewer/slots/getGuestBusyTimesForReschedule.test.ts | 3 +++ packages/trpc/server/routers/viewer/slots/util.ts | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts index 72af4926b67216..602065f8f33f81 100644 --- a/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts +++ b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts @@ -220,6 +220,9 @@ describe("AvailableSlotsService - _getGuestBusyTimesForReschedule", () => { }); expect(mockDependencies.userRepo.findByEmails).toHaveBeenCalled(); + // Proves the host branch executed end-to-end: early-return on attendee-initiated + // reschedules would short-circuit before the booking lookup fires. + expect(mockDependencies.bookingRepo.findByUserIdsAndDateRange).toHaveBeenCalled(); }); it("should check guest busy times when rescheduledBy is not provided (backwards compat)", async () => { diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 711a4a64d5153f..02c4c9499970bd 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -682,6 +682,12 @@ export class AvailableSlotsService { // 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. + // + // Default-safe-for-attendee semantics: if rescheduledBy is present but + // doesn't match the host's email (e.g., attendee, admin-on-behalf, team + // member), we treat it as non-host-initiated and skip guest blocking. + // This favors booking-success surface over host intent when the initiator + // cannot be positively identified as the host. if (rescheduledBy) { const hostEmail = original.user?.email; const isHostReschedule = hostEmail && rescheduledBy.toLowerCase() === hostEmail.toLowerCase();