From 51dd9c64b809fe76eb60060d03f4ef11e8844df9 Mon Sep 17 00:00:00 2001 From: Bcornish Date: Wed, 15 Apr 2026 21:13:38 +0700 Subject: [PATCH 1/3] feat(slots): thread rescheduledBy param from URL through to tRPC input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split of #28636 (Part B of 3). Plumbing only — adds a new `rescheduledBy` search param that flows from the URL through `useScheduleForEvent` → `useSchedule` → the tRPC `getSchedule` input schema. Nothing consumes the field yet; it will be read by `_getGuestBusyTimesForReschedule` in Part C to gate guest busy-time blocking on host-initiated reschedules. Concretely: - `apps/web/modules/schedules/hooks/useEvent.ts`: reads `searchParams.get("rescheduledBy")` alongside the existing `rescheduleUid` read and forwards it to `useSchedule`. - `apps/web/modules/schedules/hooks/useSchedule.ts`: adds `rescheduledBy` to `UseScheduleWithCacheArgs` and passes it through to the tRPC query input. - `packages/trpc/server/routers/viewer/slots/types.ts`: adds `rescheduledBy: z.string().nullish()` to `getScheduleSchemaObject`. Backwards compatible — the field is nullish on both the URL and the schema, so older clients keep working unchanged. --- apps/web/modules/schedules/hooks/useEvent.ts | 2 ++ apps/web/modules/schedules/hooks/useSchedule.ts | 3 +++ packages/trpc/server/routers/viewer/slots/types.ts | 1 + 3 files changed, 6 insertions(+) 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/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(), From e21ce05c7c35bc859851a4524dfead46770d5f14 Mon Sep 17 00:00:00 2001 From: Bcornish Date: Wed, 15 Apr 2026 21:10:10 +0700 Subject: [PATCH 2/3] feat(repos): add guest-availability booking + user lookup methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split of #28636 (Part A of 3). Pure additive infra — no call sites change and no existing behaviour is altered. This layer is the data-access foundation that Parts B (frontend `rescheduledBy` plumbing) and C (slots/util.ts business logic) build on. BookingRepository: - findByUidIncludeAttendeeEmails(uid): fetches an original booking's attendee emails and the host user's email, used to detect who the reschedule initiator is and resolve attendees to Cal.com users. - findByUserIdsAndDateRange({ userIds, userEmails, dateFrom, dateTo, excludeUid? }): finds ACCEPTED/PENDING bookings overlapping a date range by userId or attendee email (case-insensitive), with an excludeUid parameter applied at the database level so the caller cannot accidentally include the very booking being rescheduled. UserRepository: - findByEmails({ emails }): resolves a list of emails to Cal.com users, checking both primary email and verified secondary emails, case-insensitively, with input deduplication before the query and output deduplication by user id. Uses Promise.all to fan out the two lookups concurrently. Tests cover: empty-input short-circuits, primary vs secondary lookup, dedup across both lookups, case-insensitive normalization, excludeUid, OR clause composition, and the select shape used downstream. --- .../repositories/BookingRepository.test.ts | 181 ++++++++++++++++++ .../repositories/BookingRepository.ts | 50 +++++ .../users/repositories/UserRepository.test.ts | 101 ++++++++++ .../users/repositories/UserRepository.ts | 30 +++ 4 files changed, 362 insertions(+) diff --git a/packages/features/bookings/repositories/BookingRepository.test.ts b/packages/features/bookings/repositories/BookingRepository.test.ts index 965673ad009001..abc27f17198a8d 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,176 @@ describe("BookingRepository", () => { expect(mockPrismaClient.$queryRaw).toHaveBeenCalledTimes(1); }); }); + + describe("findByUidIncludeAttendeeEmails", () => { + 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); + + 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 } }, + user: { 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, + }, + }) + ); + }); + + it("should include excludeUid in query when provided", async () => { + await repository.findByUserIdsAndDateRange({ + userIds: [1], + userEmails: [], + dateFrom, + dateTo, + excludeUid: "booking-to-exclude", + }); + + expect(mockPrismaClient.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 96b20fdf373215..fca1e9d6289626 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -2137,4 +2137,54 @@ 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 } }, + user: { select: { email: true } }, + }, + }); + } + + async findByUserIdsAndDateRange({ + userIds, + userEmails, + dateFrom, + dateTo, + excludeUid, + }: { + userIds: number[]; + userEmails: string[]; + dateFrom: Date; + dateTo: Date; + excludeUid?: string; + }) { + 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 } } } }] + : []), + ], + ...(excludeUid ? { uid: { not: excludeUid } } : {}), + }, + 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/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index 24878a56846295..1b695a2a937ed5 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -1506,4 +1506,34 @@ export class UserRepository { return { email: user.email, username: user.username }; } + + async findByEmails({ emails }: { emails: string[] }) { + if (!emails.length) return []; + + const normalized = Array.from(new Set(emails.map((e) => e.toLowerCase()))); + + 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 }, + }), + ]); + + 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 15cea3a17d51b9ea2f190af9aab4aeed92eca7a9 Mon Sep 17 00:00:00 2001 From: Bcornish Date: Wed, 15 Apr 2026 21:19:51 +0700 Subject: [PATCH 3/3] feat(slots): check guest availability when host reschedules (#16378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split of #28636 (Part C of 3 — depends on Parts A and B). When the host reschedules a booking, check whether any attendee is a Cal.com user and collect their busy times so the host only sees mutually available slots. Attendee-initiated reschedules still see all slots — guest-availability gating only kicks in when `rescheduledBy` matches the host email. Flow: 1. `slots/util.ts` reads `rescheduleUid` and `rescheduledBy` from the tRPC input (threaded through in Part B) and fans out `getGuestBusyTimesForReschedule` in the existing booking-fetch Promise.all. 2. `_getGuestBusyTimesForReschedule` (wrapped with `withReporting`): - short-circuits when no rescheduleUid or schedulingType is COLLECTIVE (team members already coordinated via round-robin); - loads the original booking with attendee + host email via `BookingRepository.findByUidIncludeAttendeeEmails` (Part A); - compares `rescheduledBy` to the host email (case-insensitive). If it is an attendee, returns `[]` — no blocking; - resolves attendee emails to Cal.com users via `UserRepository.findByEmails` (Part A) and filters the booking query to those users only, so non-Cal.com guests do not pollute the OR-filter; - fetches overlapping bookings via `BookingRepository.findByUserIdsAndDateRange` with excludeUid at the DB level (Part A); - on any failure, returns `[]` and logs a `warn` so operators can detect regressions without paging on a non-blocking path. 3. `getUserAvailability.ts` accepts the resulting `guestBusyTimes` via a new optional `initialData.guestBusyTimes` field, formats them as EventBusyDetails with a `guest-availability` source, and merges them into the per-user busy window. No existing call sites are changed — the field is optional and defaults to `[]`. Scope: - COLLECTIVE scheduling: skipped (already coordinated at booking time). - Non-Cal.com guests: ignored (no Cal.com calendar to check). - ROUND_ROBIN: fully supported. - Multi-guest reschedules: all matched guests' busy times are merged. - Older clients (no `rescheduledBy` in URL): fall through to gate on `rescheduleUid` alone. No regression in existing reschedule flows. Tests (13 new, in `packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts`): early exits, host-initiated collection, attendee-initiated no-op, case-insensitive host-email match, backwards compat when rescheduledBy is absent, multi-guest merging, excludeUid filtering, and graceful degradation on each failure mode. --- .../availability/lib/getUserAvailability.ts | 9 + .../getGuestBusyTimesForReschedule.test.ts | 491 ++++++++++++++++++ .../trpc/server/routers/viewer/slots/util.ts | 85 ++- 3 files changed, 583 insertions(+), 2 deletions(-) create mode 100644 packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts diff --git a/packages/features/availability/lib/getUserAvailability.ts b/packages/features/availability/lib/getUserAvailability.ts index eb94e638d0bc9d..83cc2cb775362d 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,13 @@ 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 detailedBusyTimesWithSource: EventBusyDetails[] = [ ...busyTimes.map((a) => ({ ...a, @@ -627,6 +635,7 @@ export class UserAvailabilityService { })), ...busyTimesFromLimits, ...busyTimesFromTeamLimits, + ...guestBusyTimesFormatted, ]; const detailedBusyTimes: UserAvailabilityBusyDetails[] = withSource 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..71b5d9f8f1b150 --- /dev/null +++ b/packages/trpc/server/routers/viewer/slots/getGuestBusyTimesForReschedule.test.ts @@ -0,0 +1,491 @@ +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"; + const hostEmail = "host@cal.com"; + + 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; + rescheduledBy?: 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: [], + user: { email: hostEmail }, + }); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: hostEmail, + 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" }], + user: { email: hostEmail }, + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([]); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: hostEmail, + schedulingType: null, + dateFrom, + dateTo, + }); + + expect(result).toEqual([]); + expect(mockDependencies.bookingRepo.findByUserIdsAndDateRange).not.toHaveBeenCalled(); + }); + }); + + 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, + 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 pass excludeUid to the booking query to filter at database level", 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: "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, + rescheduledBy: hostEmail, + schedulingType: null, + dateFrom, + 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"), + end: new Date("2026-04-10T17:00:00Z"), + }); + }); + + 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 only use Cal.com user emails in booking query, not all attendee emails", async () => { + mockDependencies.bookingRepo.findByUidIncludeAttendeeEmails.mockResolvedValue({ + id: 1, + uid: rescheduleUid, + attendees: [{ email: "caluser@cal.com" }, { email: "external@gmail.com" }], + user: { email: hostEmail }, + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 10, email: "caluser@cal.com" }]); + mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); + + await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: hostEmail, + schedulingType: null, + dateFrom, + dateTo, + }); + + expect(mockDependencies.bookingRepo.findByUserIdsAndDateRange).toHaveBeenCalledWith( + expect.objectContaining({ + userEmails: ["caluser@cal.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, + attendees: [{ email: "guest1@cal.com" }, { email: "guest2@cal.com" }], + user: { email: hostEmail }, + }); + 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, + rescheduledBy: hostEmail, + 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, + excludeUid: rescheduleUid, + }); + }); + + it("should work with ROUND_ROBIN scheduling type", 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([]); + + const result = await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: hostEmail, + 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" }], + user: { email: hostEmail }, + }); + mockDependencies.userRepo.findByEmails.mockResolvedValue([{ id: 42, email: "cal-user@example.com" }]); + mockDependencies.bookingRepo.findByUserIdsAndDateRange.mockResolvedValue([]); + + await callGetGuestBusyTimes({ + rescheduleUid, + rescheduledBy: hostEmail, + 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"], + 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 842f97fea3c5c4..a4e748e214664a 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -66,7 +66,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"; @@ -652,6 +652,79 @@ 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, + rescheduledBy, + schedulingType, + dateFrom, + dateTo, + }: { + rescheduleUid: string | null | undefined; + rescheduledBy: string | null | undefined; + schedulingType: SchedulingType | null; + dateFrom: Date; + dateTo: Date; + }): Promise<{ start: Date; end: Date }[]> { + if (!rescheduleUid || schedulingType === SchedulingType.COLLECTIVE) { + return []; + } + + try { + const original = await this.dependencies.bookingRepo.findByUidIncludeAttendeeEmails({ + uid: rescheduleUid, + }); + if (!original?.attendees?.length) return []; + + // 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).filter((e): e is string => Boolean(e)); + if (!emails.length) return []; + + 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: calUserEmails, + dateFrom, + dateTo, + excludeUid: rescheduleUid, + }); + + return guestBookings.map((b) => ({ start: b.startTime, end: b.endTime })); + } 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 }) + ); + return []; + } + } + private getGuestBusyTimesForReschedule = withReporting( + this._getGuestBusyTimesForReschedule.bind(this), + "getGuestBusyTimesForReschedule" + ); + private _getUsersWithCredentials({ hosts, }: { @@ -726,7 +799,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 +808,13 @@ export class AvailableSlotsService { userIdAndEmailMap, }), this.getOOODates(startTimeDate, endTimeDate, allUserIds), + this.getGuestBusyTimesForReschedule({ + rescheduleUid: input.rescheduleUid, + rescheduledBy: input.rescheduledBy, + schedulingType: eventType.schedulingType, + dateFrom: startTimeDate, + dateTo: endTimeDate, + }), ]); const bookingLimits = @@ -825,6 +905,7 @@ export class AvailableSlotsService { busyTimesFromLimitsBookings: busyTimesFromLimitsBookingsAllUsers, busyTimesFromLimits: busyTimesFromLimitsMap, eventTypeForLimits: eventType && (bookingLimits || durationLimits) ? eventType : null, + guestBusyTimes, }, }); /* We get all users working hours and busy slots */