Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 6 additions & 6 deletions apps/web/modules/schedules/hooks/useEvent.ts
Original file line number Diff line number Diff line change
@@ -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<typeof useEvent>;
export type useScheduleForEventReturnType = ReturnType<typeof useScheduleForEvent>;
Expand Down Expand Up @@ -106,6 +104,7 @@ export const useScheduleForEvent = ({

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

const schedule = useSchedule({
username: usernameFromStore ?? username,
Expand All @@ -115,6 +114,7 @@ export const useScheduleForEvent = ({
selectedDate,
dayCount,
rescheduleUid,
rescheduledBy,
month: monthFromStore ?? month,
duration: durationFromStore ?? duration,
isTeamEvent,
Expand Down
7 changes: 4 additions & 3 deletions apps/web/modules/schedules/hooks/useSchedule.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 = {
Expand All @@ -22,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 @@ -60,6 +59,7 @@ export const useSchedule = ({
duration,
dayCount,
rescheduleUid,
rescheduledBy,
isTeamEvent,
orgSlug,
teamMemberEmail,
Expand Down Expand Up @@ -108,6 +108,7 @@ export const useSchedule = ({
timeZone: timezone ?? "PLACEHOLDER_TIMEZONE",
duration: duration ? `${duration}` : undefined,
rescheduleUid,
rescheduledBy,
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: 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,
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 } }],
}),
})
);
});

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 @@ -2213,4 +2213,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,
},
});
}
}
Loading
Loading