Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d4de6e2
feat: add findByEmails to UserRepository
bcornish1797 Mar 27, 2026
bd9929d
feat: add booking lookup methods for guest availability
bcornish1797 Mar 27, 2026
4a761e6
feat: merge guest busy times into availability computation
bcornish1797 Mar 27, 2026
4d728e1
feat: check guest availability when host reschedules (#16378)
bcornish1797 Mar 27, 2026
0d29c21
fix: address review feedback for guest availability rescheduling
bcornish1797 Mar 27, 2026
d558910
test: add unit tests for guest availability during reschedule
bcornish1797 Mar 28, 2026
4475b4f
refactor: improve error handling and move filtering to database level
bcornish1797 Mar 28, 2026
a2b98d7
fix: add host-initiator gating and narrow email filter
bcornish1797 Mar 28, 2026
b545426
fix: remove broken initiator gating, fix test contract conflict
bcornish1797 Mar 29, 2026
78269db
fix: correct variable name in excludeUid test (mockPrisma -> mockPris…
bcornish1797 Mar 29, 2026
fa147ce
fix: gate guest busy-time blocking on host-initiated reschedules only
bcornish1797 Mar 29, 2026
50c4055
fix: use empty string instead of undefined for EventBusyDetails source
bcornish1797 Mar 29, 2026
f8174f6
fix: resolve TS2802 Set iteration error and apply biome formatting
bcornish1797 Mar 29, 2026
13edf26
chore: trigger CLA recheck
bcornish1797 Apr 1, 2026
5e327e8
feat(slots): add observability to guest-busy-times degradation path
Apr 15, 2026
69733d3
fix(availability): drop Day.js round-trip in guestBusyTimesFormatted
bcornish1797 Apr 21, 2026
fb8da3c
fix(useEvent): restore restrictionSchedule/useStableTimezone wiring
bcornish1797 Apr 21, 2026
989b419
fix(users): findByEmails returns matchedEmails (primary and verified …
sochanged Apr 21, 2026
23b3609
fix(schedule): fall back to V1 slots when rescheduledBy is set
sochanged Apr 21, 2026
5ca694d
chore(tests): drop comments that restate their assertions
sochanged Apr 21, 2026
efb6cbc
test(users): tighten findByEmails assertions
sochanged Apr 21, 2026
c050464
fix(slots): bound degradation log payload in guest busy-time fallback
sochanged Apr 21, 2026
0a83d1a
fix(bookings): dedupe findByUserIdsAndDateRange results by uid
sochanged Apr 21, 2026
e8a7275
chore(slots): address round-3 review nits
sochanged Apr 21, 2026
e5ee2dc
fix(slots): drop stale RoutingFormResponseRepository import and fix g…
sochanged Apr 21, 2026
45899ba
fix(availability): always set source on guestBusyTimesFormatted entries
sochanged Apr 21, 2026
37f58e2
fix(bookings): skip excludeUid filter on empty string + lock in dedup…
sochanged Apr 21, 2026
7cec9fb
fix(schedule): normalize rescheduledBy URL param client-side
sochanged Apr 21, 2026
577cb17
docs+test(slots): document attendee-default gating + tighten host-bra…
sochanged Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions apps/web/modules/schedules/hooks/useEvent.ts
Original file line number Diff line number Diff line change
@@ -1,11 +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 { 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 { useBookerTime } from "@calcom/features/bookings/Booker/hooks/useBookerTime";
import { useSchedule } from "@calcom/web/modules/schedules/hooks/useSchedule";
import { shallow } from "zustand/shallow";

export type useEventReturnType = ReturnType<typeof useEvent>;
export type useScheduleForEventReturnType = ReturnType<typeof useScheduleForEvent>;
Expand Down Expand Up @@ -71,6 +70,7 @@ export const useScheduleForEvent = ({
isTeamEvent,
useApiV2 = true,
bookerLayout,
restrictionSchedule,
}: {
username?: string | null;
eventSlug?: string | null;
Expand All @@ -92,24 +92,29 @@ export const useScheduleForEvent = ({
extraDays: number;
columnViewExtraDays: { current: number };
};
restrictionSchedule?: { id: number | null; useBookerTimezone: boolean };
}) => {
const { timezone } = useBookerTime();
const { timezone: rawTimezone } = useBookerTime();
const [usernameFromStore, eventSlugFromStore, monthFromStore, durationFromStore] = useBookerStoreContext(
(state) => [state.username, state.eventSlug, state.month, state.selectedDuration],
shallow
);

const effectiveTimezone = useStableTimezone(rawTimezone, restrictionSchedule);

const searchParams = useCompatSearchParams();
const rescheduleUid = searchParams?.get("rescheduleUid");
const rescheduledBy = searchParams?.get("rescheduledBy");

const schedule = useSchedule({
username: usernameFromStore ?? username,
eventSlug: eventSlugFromStore ?? eventSlug,
eventId,
timezone,
timezone: effectiveTimezone,
selectedDate,
dayCount,
rescheduleUid,
rescheduledBy,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
month: monthFromStore ?? month,
duration: durationFromStore ?? duration,
isTeamEvent,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/modules/schedules/hooks/useSchedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,6 +59,7 @@ export const useSchedule = ({
duration,
dayCount,
rescheduleUid,
rescheduledBy,
isTeamEvent,
orgSlug,
teamMemberEmail,
Expand Down Expand Up @@ -102,6 +104,7 @@ export const useSchedule = ({
timeZone: timezone ?? "PLACEHOLDER_TIMEZONE",
duration: duration ? `${duration}` : undefined,
rescheduleUid,
rescheduledBy,
Comment on lines 106 to +107
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Sanitize cache/log handling before adding email-valued rescheduledBy.

Line 107 forwards rescheduledBy, which is later compared to original.user.email, so this is an email/user identifier. The slots cache currently JSON-stringifies the full input and logs the resulting cacheKey in packages/trpc/server/routers/viewer/slots/util.ts Lines 110, 131, and 139. Please make sure the server hashes or redacts that cache key/log payload so this new identifier is not persisted or emitted in raw form.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/modules/schedules/hooks/useSchedule.ts` around lines 106 - 107, The
code forwards an email-valued rescheduledBy from useSchedule.ts into the slots
cache which currently JSON-stringifies inputs and logs cacheKey in
packages/trpc/server/routers/viewer/slots/util.ts (see cache key handling around
the existing log sites), so sanitize before persisting or logging: update the
cache key generation in the slots util to either remove/redact the rescheduledBy
field from the input payload or replace it with a deterministic hash (e.g.,
SHA-256) of the email, and ensure the same sanitized value is used wherever
cacheKey is stored or logged (the log calls near the existing cacheKey creation
must log the hashed/redacted key instead of raw JSON); keep the
redaction/hashing logic centralized in the cache key generation function so all
callers (including where rescheduledBy is passed) inherit the fix.

orgSlug,
teamMemberEmail,
routedTeamMemberIds,
Expand Down
9 changes: 9 additions & 0 deletions packages/features/availability/lib/getUserAvailability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export type GetUserAvailabilityInitialData = {
bookingLimits?: unknown;
includeManagedEventsInLimits: boolean;
} | null;
guestBusyTimes?: { start: Date; end: Date }[];
};

export type GetAvailabilityUser = GetUserAvailabilityInitialData["user"];
Expand Down Expand Up @@ -617,6 +618,13 @@ export class UserAvailabilityService {
};
}

const guestBusyTimesFormatted: EventBusyDetails[] = (initialData?.guestBusyTimes ?? []).map((t) => ({
start: t.start.toISOString(),
end: t.end.toISOString(),
title: "Guest busy",
source: withSource ? "guest-availability" : "",
}));

const detailedBusyTimesWithSource: EventBusyDetails[] = [
...busyTimes.map((a) => ({
...a,
Expand All @@ -627,6 +635,7 @@ export class UserAvailabilityService {
})),
...busyTimesFromLimits,
...busyTimesFromTeamLimits,
...guestBusyTimesFormatted,
];

const detailedBusyTimes: UserAvailabilityBusyDetails[] = withSource
Expand Down
182 changes: 182 additions & 0 deletions packages/features/bookings/repositories/BookingRepository.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import type { PrismaClient } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BookingRepository } from "./BookingRepository";

describe("BookingRepository", () => {
let repository: BookingRepository;
let mockPrismaClient: {
$queryRaw: ReturnType<typeof vi.fn>;
booking: {
findUnique: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
};
};

beforeEach(() => {
vi.clearAllMocks();

mockPrismaClient = {
$queryRaw: vi.fn(),
booking: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
};

repository = new BookingRepository(mockPrismaClient as unknown as PrismaClient);
Expand Down Expand Up @@ -58,4 +67,177 @@ 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 } }],
}),
})
);
});
Comment on lines +120 to +149
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Assert the userId predicate in the userId-only path.

This test currently verifies status/date overlap but would still pass if the repository forgot to filter by userIds, causing guest busy-time lookups to include unrelated bookings.

Suggested assertion
       expect(mockPrismaClient.booking.findMany).toHaveBeenCalledWith(
         expect.objectContaining({
           where: expect.objectContaining({
             status: { in: [BookingStatus.ACCEPTED, BookingStatus.PENDING] },
             AND: [{ startTime: { lt: dateTo } }, { endTime: { gt: dateFrom } }],
+            OR: expect.arrayContaining([{ userId: { in: [10] } }]),
           }),
         })
       );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/features/bookings/repositories/BookingRepository.test.ts` around
lines 120 - 149, The test for findByUserIdsAndDateRange is missing an assertion
that the query filters by userIds; update the BookingRepository.test.ts case
(the one calling repository.findByUserIdsAndDateRange and mocking
mockPrismaClient.booking.findMany) to assert that the prisma call's where
includes the userId predicate (e.g. userId: { in: [10] } or equivalent)
alongside the existing status and date overlap assertions so the test fails if
userIds are not applied.


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 () => {
const repo = new BookingRepository(mockPrismaClient as unknown as PrismaClient);
await repo.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" },
}),
})
);
});
});
});
50 changes: 50 additions & 0 deletions packages/features/bookings/repositories/BookingRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
Loading
Loading