-
Notifications
You must be signed in to change notification settings - Fork 12.7k
feat(slots): check guest availability when host reschedules (#16378) #28911
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" : "", | ||
| })); | ||
|
Comment on lines
+621
to
+626
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replace hardcoded As per coding guidelines, "Add translations to 🤖 Prompt for AI Agents |
||
|
|
||
| const detailedBusyTimesWithSource: EventBusyDetails[] = [ | ||
| ...busyTimes.map((a) => ({ | ||
| ...a, | ||
|
|
@@ -627,6 +635,7 @@ export class UserAvailabilityService { | |
| })), | ||
| ...busyTimesFromLimits, | ||
| ...busyTimesFromTeamLimits, | ||
| ...guestBusyTimesFormatted, | ||
| ]; | ||
|
|
||
| const detailedBusyTimes: UserAvailabilityBusyDetails[] = withSource | ||
|
|
||
| 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); | ||
|
|
@@ -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 } }], | ||
| }), | ||
| }) | ||
| ); | ||
| }); | ||
|
Comment on lines
+120
to
+149
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Assert the Right now this test can pass even if the repository forgets to filter by Suggested test hardening 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 } }],
- }),
- })
- );
+ const callArgs = mockPrismaClient.booking.findMany.mock.calls[0][0];
+ expect(callArgs.where).toEqual(
+ expect.objectContaining({
+ status: { in: [BookingStatus.ACCEPTED, BookingStatus.PENDING] },
+ AND: [{ startTime: { lt: dateTo } }, { endTime: { gt: dateFrom } }],
+ })
+ );
+ expect(JSON.stringify(callArgs.where)).toContain('"userId"');🤖 Prompt for AI Agents |
||
|
|
||
| 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" }, | ||
| }), | ||
| }) | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: calcom/cal.diy
Length of output: 45708
🏁 Script executed:
Repository: calcom/cal.diy
Length of output: 40
🏁 Script executed:
Repository: calcom/cal.diy
Length of output: 10179
🏁 Script executed:
Repository: calcom/cal.diy
Length of output: 228
🏁 Script executed:
# Get complete GetAvailableSlotsInput_2024_04_15 type definition cat packages/platform/types/slots/slots-2024-04-15/inputs/index.tsRepository: calcom/cal.diy
Length of output: 6106
🏁 Script executed:
Repository: calcom/cal.diy
Length of output: 40
🏁 Script executed:
Repository: calcom/cal.diy
Length of output: 1891
🏁 Script executed:
Repository: calcom/cal.diy
Length of output: 40
API V2
/slots/availableendpoint lacksrescheduledBysupport, creating functional divergence with tRPC in host-reschedule guest-gating logic.The API V2
GetAvailableSlotsInput_2024_04_15type definition does not include arescheduledByfield, so the endpoint cannot accept this parameter. Meanwhile, the tRPC slots endpoint usesrescheduledByto determine whether guest busy-times should be included in available slots—only applying blocking when the reschedule is initiated by the host (matching emails case-insensitively). Without this parameter in API V2, the endpoint applies guest busy-time blocking uniformly, regardless of who initiated the reschedule.To achieve parity, either:
rescheduledByto the API V2 input type and implement the same host-reschedule detection logic, or🤖 Prompt for AI Agents