From 0acb93f0378cfdf7ab8032c663a9afab79168902 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Fri, 27 Mar 2026 10:04:25 +0700 Subject: [PATCH 01/15] 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 b69b99d5500534..75a4efb50b69cd 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -1515,4 +1515,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 cc345824f21ede2a97f26b3591c24faf353d10dc Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Fri, 27 Mar 2026 10:04:25 +0700 Subject: [PATCH 02/15] 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 908dd4e542bfd2..5b63037971d54b 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -2213,4 +2213,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 469a4ff884dd19c32f4c52896a5edc04917f8691 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Fri, 27 Mar 2026 10:04:46 +0700 Subject: [PATCH 03/15] 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 dbc6d5f8e23492c22df8698bbcdc76c229f85051 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Fri, 27 Mar 2026 10:04:46 +0700 Subject: [PATCH 04/15] 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 433672093bbef3..663231e301edc0 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -829,6 +829,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, }: { @@ -903,7 +952,7 @@ export class AvailableSlotsService { const allUserIds = Array.from(userIdAndEmailMap.keys()); const bookingRepo = this.dependencies.bookingRepo; - const [currentBookingsAllUsers, outOfOfficeDaysAllUsers] = await Promise.all([ + const [currentBookingsAllUsers, outOfOfficeDaysAllUsers, guestBusyTimes] = await Promise.all([ bookingRepo.findAllExistingBookingsForEventTypeBetween({ startDate: startTimeDate, endDate: endTimeDate, @@ -912,6 +961,12 @@ export class AvailableSlotsService { userIdAndEmailMap, }), this.getOOODates(startTimeDate, endTimeDate, allUserIds), + this.getGuestBusyTimesForReschedule({ + rescheduleUid: input.rescheduleUid, + schedulingType: eventType.schedulingType, + dateFrom: startTimeDate, + dateTo: endTimeDate, + }), ]); const bookingLimits = @@ -1026,6 +1081,7 @@ export class AvailableSlotsService { eventTypeForLimits: eventType && (bookingLimits || durationLimits) ? eventType : null, teamBookingLimits: teamBookingLimitsMap, teamForBookingLimits: teamForBookingLimits, + guestBusyTimes, }, }); /* We get all users working hours and busy slots */ From fac069922bc3ce13e1e3de2ae4ace9bf8db64fe8 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Fri, 27 Mar 2026 19:23:04 +0000 Subject: [PATCH 05/15] 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 75a4efb50b69cd..9ac207a83e989f 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -1519,24 +1519,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 663231e301edc0..287926d513c0c9 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -863,7 +863,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 cf7213c676eeca70ddd30631474560bf48fcc754 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Sun, 29 Mar 2026 00:24:12 +0700 Subject: [PATCH 06/15] 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 f10556a794fcf8ab93c220acbf81fe0567d15217 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Sun, 29 Mar 2026 00:24:12 +0700 Subject: [PATCH 07/15] 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 5b63037971d54b..82952b2d95dbea 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -2230,11 +2230,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 []; @@ -2248,6 +2250,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 287926d513c0c9..f63a9931668f56 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -848,30 +848,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 cf12e568767c5daaa136e8e2f9f64b860288f24c Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Sun, 29 Mar 2026 02:32:34 +0700 Subject: [PATCH 08/15] 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 f63a9931668f56..f5d4f98e135fd1 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -836,11 +836,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 }[]> { @@ -854,6 +856,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)); @@ -862,9 +871,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, @@ -967,6 +980,7 @@ export class AvailableSlotsService { this.getGuestBusyTimesForReschedule({ rescheduleUid: input.rescheduleUid, schedulingType: eventType.schedulingType, + hostUserIds: allUserIds, dateFrom: startTimeDate, dateTo: endTimeDate, }), From ea75c7d8415c862a060013592919334dd469aa43 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Sun, 29 Mar 2026 11:35:12 +0700 Subject: [PATCH 09/15] 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 f5d4f98e135fd1..e5e41a0d1aba23 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -836,13 +836,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 }[]> { @@ -856,12 +854,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) @@ -980,7 +979,6 @@ export class AvailableSlotsService { this.getGuestBusyTimesForReschedule({ rescheduleUid: input.rescheduleUid, schedulingType: eventType.schedulingType, - hostUserIds: allUserIds, dateFrom: startTimeDate, dateTo: endTimeDate, }), From ca44a40e507618c5e4320868a1f305c1b67b4650 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Sun, 29 Mar 2026 11:41:12 +0700 Subject: [PATCH 10/15] 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 b0065efa9bf2225e9719bb72066808dd201f4ff5 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Sun, 29 Mar 2026 19:56:03 +0700 Subject: [PATCH 11/15] 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 46b25d527b8c20..c0cfd807451401 100644 --- a/apps/web/modules/schedules/hooks/useEvent.ts +++ b/apps/web/modules/schedules/hooks/useEvent.ts @@ -106,6 +106,7 @@ export const useScheduleForEvent = ({ const searchParams = useCompatSearchParams(); const rescheduleUid = searchParams?.get("rescheduleUid"); + const rescheduledBy = searchParams?.get("rescheduledBy"); const schedule = useSchedule({ username: usernameFromStore ?? username, @@ -115,6 +116,7 @@ export const useScheduleForEvent = ({ selectedDate, dayCount, rescheduleUid, + rescheduledBy, month: monthFromStore ?? month, duration: durationFromStore ?? duration, isTeamEvent, diff --git a/apps/web/modules/schedules/hooks/useSchedule.ts b/apps/web/modules/schedules/hooks/useSchedule.ts index 53f01ba8417469..2fb90ba7f7b3c6 100644 --- a/apps/web/modules/schedules/hooks/useSchedule.ts +++ b/apps/web/modules/schedules/hooks/useSchedule.ts @@ -22,6 +22,7 @@ export type UseScheduleWithCacheArgs = { duration?: number | null; dayCount?: number | null; rescheduleUid?: string | null; + rescheduledBy?: string | null; isTeamEvent?: boolean; orgSlug?: string; teamMemberEmail?: string | null; @@ -60,6 +61,7 @@ export const useSchedule = ({ duration, dayCount, rescheduleUid, + rescheduledBy, isTeamEvent, orgSlug, teamMemberEmail, @@ -108,6 +110,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 82952b2d95dbea..78fbd28d4ae0ad 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -2221,6 +2221,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 01320ea4f4a193..c9fad5a203e664 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -27,6 +27,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 e5e41a0d1aba23..0f170669c9bdf0 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -835,11 +835,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; @@ -854,13 +856,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) @@ -978,6 +984,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 165b3c933ab55cd126356e67fd12c0f9487df391 Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Sun, 29 Mar 2026 19:56:08 +0700 Subject: [PATCH 12/15] 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 e77ddf72a1a221957fbfce1de2bf886a54ef7bab Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Sun, 29 Mar 2026 14:15:13 +0000 Subject: [PATCH 13/15] 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 | 10 ++++------ apps/web/modules/schedules/hooks/useSchedule.ts | 4 +--- .../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/types.ts | 3 +-- packages/trpc/server/routers/viewer/slots/util.ts | 13 +++++-------- 8 files changed, 22 insertions(+), 38 deletions(-) diff --git a/apps/web/modules/schedules/hooks/useEvent.ts b/apps/web/modules/schedules/hooks/useEvent.ts index c0cfd807451401..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; diff --git a/apps/web/modules/schedules/hooks/useSchedule.ts b/apps/web/modules/schedules/hooks/useSchedule.ts index 2fb90ba7f7b3c6..9ac9e51f73d5e7 100644 --- a/apps/web/modules/schedules/hooks/useSchedule.ts +++ b/apps/web/modules/schedules/hooks/useSchedule.ts @@ -1,5 +1,3 @@ -import { useSearchParams } from "next/navigation"; - import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe"; import { sdkActionManager } from "@calcom/embed-core/src/sdk-event"; import { useBookerStore } from "@calcom/features/bookings/Booker/store"; @@ -9,7 +7,7 @@ import { useTimesForSchedule } from "@calcom/features/schedules/hooks/useTimesFo import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams"; import { PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS } from "@calcom/lib/constants"; import { trpc } from "@calcom/trpc/react"; - +import { useSearchParams } from "next/navigation"; import { useApiV2AvailableSlots } from "./useApiV2AvailableSlots"; export type UseScheduleWithCacheArgs = { 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 9ac207a83e989f..ac5d4f4b11c2d4 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -1519,7 +1519,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/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index c9fad5a203e664..0567b4443284bf 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -1,7 +1,6 @@ import type { IncomingMessage } from "node:http"; -import { z } from "zod"; - import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema"; +import { z } from "zod"; const isValidDateString = (val: string) => !isNaN(Date.parse(val)); diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 0f170669c9bdf0..136002510d426b 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -9,6 +9,7 @@ import type { GetAvailabilityUser, UserAvailabilityService, } from "@calcom/features/availability/lib/getUserAvailability"; +import type { IGetAvailableSlots } from "@calcom/features/bookings/Booker/hooks/useAvailableTimeSlots"; import type { CheckBookingLimitsService } from "@calcom/features/bookings/lib/checkBookingLimits"; import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts"; import type { QualifiedHostsService } from "@calcom/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials"; @@ -16,12 +17,14 @@ import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEvent import type { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import type { BusyTimesService } from "@calcom/features/busyTimes/services/getBusyTimes"; import type { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes"; +import type { OrgMembershipLookup } from "@calcom/features/di/modules/OrgMembershipLookup"; import type { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents"; import type { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; import type { PrismaOOORepository } from "@calcom/features/ooo/repositories/PrismaOOORepository"; import type { IRedisService } from "@calcom/features/redis/IRedisService"; +import type { RoutingFormResponseRepository } from "@calcom/features/routing-forms/repositories/RoutingFormResponseRepository"; import { buildDateRanges } from "@calcom/features/schedules/lib/date-ranges"; import getSlots from "@calcom/features/schedules/lib/slots"; import type { ScheduleRepository } from "@calcom/features/schedules/repositories/ScheduleRepository"; @@ -48,7 +51,6 @@ import { import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { withReporting } from "@calcom/lib/sentryWrapper"; -import type { RoutingFormResponseRepository } from "@calcom/features/routing-forms/repositories/RoutingFormResponseRepository"; import { PeriodType, SchedulingType } from "@calcom/prisma/enums"; import type { CalendarFetchMode, EventBusyDate, EventBusyDetails } from "@calcom/types/Calendar"; import type { CredentialForCalendarService } from "@calcom/types/Credential"; @@ -57,8 +59,6 @@ import type { Logger } from "tslog"; import { v4 as uuid } from "uuid"; import type { TGetScheduleInputSchema } from "./getSchedule.schema"; import type { GetScheduleOptions } from "./types"; -import type { OrgMembershipLookup } from "@calcom/features/di/modules/OrgMembershipLookup"; -import type { IGetAvailableSlots } from "@calcom/features/bookings/Booker/hooks/useAvailableTimeSlots"; const log = logger.getSubLogger({ prefix: ["[slots/util]"] }); const DEFAULT_SLOTS_CACHE_TTL = 2000; @@ -861,16 +861,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 5dc781f2ebd5250e597392a83e1e4e522cf7605e Mon Sep 17 00:00:00 2001 From: bcornish1797 Date: Wed, 1 Apr 2026 15:42:16 +0000 Subject: [PATCH 14/15] chore: trigger CLA recheck From 8a2740462e5c830a052ea6c230adc8c00bba702b Mon Sep 17 00:00:00 2001 From: Bcornish Date: Wed, 15 Apr 2026 18:53:02 +0700 Subject: [PATCH 15/15] 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 136002510d426b..e25e465c030871 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -887,7 +887,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 []; } }