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
2 changes: 2 additions & 0 deletions apps/web/modules/schedules/hooks/useEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -110,6 +111,7 @@ export const useScheduleForEvent = ({
selectedDate,
dayCount,
rescheduleUid,
rescheduledBy,
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,
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) Locate useApiV2AvailableSlots hook file"
HOOK_FILE="$(fd -i 'useApiV2AvailableSlots.ts' | head -n1 || true)"
if [ -z "${HOOK_FILE}" ]; then
  echo "useApiV2AvailableSlots.ts not found"
else
  echo "Found: ${HOOK_FILE}"
  echo
  echo "2) Inspect hook arg types and request serialization for rescheduledBy"
  rg -n -C4 --type=ts '\brescheduledBy\b|useApiV2AvailableSlots|available.?slots|query|params|body' "${HOOK_FILE}" || true
fi

echo
echo "3) Find server-side API V2 available-slots schema/handler references to rescheduledBy"
rg -n -C4 --type=ts '\brescheduledBy\b' apps packages | rg -n 'available.?slots|slots|v2|schema|router|handler' || true

Repository: calcom/cal.diy

Length of output: 45708


🏁 Script executed:

# Find the platform types definition for GetAvailableSlotsInput_2024_04_15
fd -i 'platform-types' --type d | head -5

Repository: calcom/cal.diy

Length of output: 40


🏁 Script executed:

# Search for GetAvailableSlotsInput_2024_04_15 definition
rg -n 'GetAvailableSlotsInput_2024_04_15' --type=ts -A 20 | head -100

Repository: calcom/cal.diy

Length of output: 10179


🏁 Script executed:

# Find the API V2 slots handler
fd -i '*slots*' -type f -path '*/api/v2/*' | grep -E '\.(ts|controller|handler)' | head -20

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.ts

Repository: calcom/cal.diy

Length of output: 6106


🏁 Script executed:

# Check how API V2 available slots service handles the input
rg -n 'rescheduledBy' apps/api/v2/src/lib/services/available-slots.service.ts --context=5

Repository: calcom/cal.diy

Length of output: 40


🏁 Script executed:

# Verify tRPC slots service actually uses rescheduledBy in implementation
rg -n 'rescheduledBy' packages/trpc/server/routers/viewer/slots/util.ts -B 2 -A 10 | head -60

Repository: calcom/cal.diy

Length of output: 1891


🏁 Script executed:

# Check if there's any other API V2 slots endpoint or service that might handle reschedule logic
rg -rn 'available.*slot' apps/api/v2/src/lib/services/ --type=ts | grep -i 'reschedule\|service'

Repository: calcom/cal.diy

Length of output: 40


API V2 /slots/available endpoint lacks rescheduledBy support, creating functional divergence with tRPC in host-reschedule guest-gating logic.

The API V2 GetAvailableSlotsInput_2024_04_15 type definition does not include a rescheduledBy field, so the endpoint cannot accept this parameter. Meanwhile, the tRPC slots endpoint uses rescheduledBy to 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:

  1. Add rescheduledBy to the API V2 input type and implement the same host-reschedule detection logic, or
  2. Accept that API V2 diverges intentionally and document the difference.
🤖 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` at line 107, The API V2
input type GetAvailableSlotsInput_2024_04_15 and the /slots/available handler
need to accept and use a rescheduledBy field to match tRPC behavior: add an
optional rescheduledBy:string to GetAvailableSlotsInput_2024_04_15, surface it
through the handler that powers /slots/available, and implement the same
host-reschedule detection logic (case-insensitive compare against host email)
used by the tRPC slots endpoint so guest busy-times are only blocked when the
reschedule was initiated by the host; alternatively, if divergence is
intentional, update the API docs/comments in the /slots/available handler and
GetAvailableSlotsInput_2024_04_15 to explicitly state the behavioral difference.

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" : "",
}));
Comment on lines +621 to +626
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

Replace hardcoded "Guest busy" with an i18n key-backed string.
This introduces a user-facing English literal in a TypeScript path; please route it through localization resources instead of embedding raw text.

As per coding guidelines, "Add translations to packages/i18n/locales/en/common.json for all UI strings".

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

In `@packages/features/availability/lib/getUserAvailability.ts` around lines 621 -
626, Replace the hardcoded "Guest busy" string in the guestBusyTimesFormatted
mapping with a localization key lookup: add a new key (e.g.
"availability.guestBusy") to packages/i18n/locales/en/common.json and use the
i18n accessor used across the codebase (the project's translate/i18n function)
in getUserAvailability.ts when building EventBusyDetails (replace the literal in
guestBusyTimesFormatted with the i18n call), ensuring fallback to the English
string if translation is missing and keeping the rest of the object shape
unchanged.


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
181 changes: 181 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,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
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 explicitly in the userId-only case.

Right now this test can pass even if the repository forgets to filter by userIds and only keeps status/date overlap.

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
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, In the userId-only test case in BookingRepository.test.ts,
explicitly assert the userId predicate so the test fails if filtering by userIds
is omitted: after calling the repository method under test (the "userId-only"
scenario), iterate the returned bookings and assert booking.userId ===
expectedUserId for each result (and/or assert the set of returned userIds equals
the single expectedUserId); this ensures the test not only checks status/date
overlap but also enforces that only bookings for the specified user are
returned.


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" },
}),
})
);
});
});
});
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,
},
});
}
}
Loading
Loading