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
146 changes: 145 additions & 1 deletion apps/web/test/lib/getSchedule/selectedSlots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,4 +401,148 @@ describe("getSchedule", () => {
expect(remainingSlots).toHaveLength(2);
});
});
});

describe("Round-Robin capacity-based reservations", () => {
beforeEach(async () => {
await prisma.selectedSlots.deleteMany({});
});

test("should show available slots when reservations < host count", async () => {
vi.setSystemTime("2024-05-31T01:30:00Z");
const yesterdayDateString = "2024-05-30";
const plus2DateString = "2024-06-02";
const plus5DateString = "2024-06-05";

const scenarioData: ScheduleScenario = {
...getBaseScenarioData(),
eventTypes: [
{
id: 1,
slotInterval: 45,
length: 45,
users: [{ id: 101 }, { id: 102 }, { id: 103 }], // 3 hosts
schedulingType: "ROUND_ROBIN",
},
],
users: [
...getBaseScenarioData().users,
{
...TestData.users.example,
id: 102,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
},
{
...TestData.users.example,
id: 103,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
},
],
selectedSlots: [
{
eventTypeId: 1,
userId: -1,
slotUtcStartDate: new Date(`${plus2DateString}T04:00:00.000Z`),
slotUtcEndDate: new Date(`${plus2DateString}T04:45:00.000Z`),
uid: "rr-reservation-1",
releaseAt: new Date(Date.now() + 1000 * 60 * 60),
},
{
eventTypeId: 1,
userId: -1,
slotUtcStartDate: new Date(`${plus2DateString}T04:00:00.000Z`),
slotUtcEndDate: new Date(`${plus2DateString}T04:45:00.000Z`),
uid: "rr-reservation-2",
releaseAt: new Date(Date.now() + 1000 * 60 * 60),
},
],
};

await createBookingScenario(scenarioData);
await mockCalendarToHaveNoBusySlots("googlecalendar");

const schedule = await availableSlotsService.getAvailableSlots({
input: getTestScheduleInput({ yesterdayDateString, plus5DateString }),
});

// With 2 reservations and 3 hosts, all slots should be available
expect(schedule).toHaveTimeSlots([
"04:00:00.000Z",
"04:45:00.000Z",
"05:30:00.000Z",
"06:15:00.000Z",
"07:00:00.000Z",
"07:45:00.000Z",
"08:30:00.000Z",
"09:15:00.000Z",
"10:00:00.000Z",
"10:45:00.000Z",
"11:30:00.000Z",
], {
dateString: plus2DateString,
});
});

test("should hide slots when reservations >= host count", async () => {
vi.setSystemTime("2024-05-31T01:30:00Z");
const yesterdayDateString = "2024-05-30";
const plus2DateString = "2024-06-02";
const plus5DateString = "2024-06-05";

const scenarioData: ScheduleScenario = {
...getBaseScenarioData(),
eventTypes: [
{
id: 1,
slotInterval: 45,
length: 45,
users: [{ id: 101 }, { id: 102 }], // 2 hosts
schedulingType: "ROUND_ROBIN",
},
],
users: [
...getBaseScenarioData().users,
{
...TestData.users.example,
id: 102,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
},
],
selectedSlots: [
{
eventTypeId: 1,
userId: -1,
slotUtcStartDate: new Date(`${plus2DateString}T04:00:00.000Z`),
slotUtcEndDate: new Date(`${plus2DateString}T04:45:00.000Z`),
uid: "rr-reservation-1",
releaseAt: new Date(Date.now() + 1000 * 60 * 60),
},
{
eventTypeId: 1,
userId: -1,
slotUtcStartDate: new Date(`${plus2DateString}T04:00:00.000Z`),
slotUtcEndDate: new Date(`${plus2DateString}T04:45:00.000Z`),
uid: "rr-reservation-2",
releaseAt: new Date(Date.now() + 1000 * 60 * 60),
},
],
};

await createBookingScenario(scenarioData);
await mockCalendarToHaveNoBusySlots("googlecalendar");

const schedule = await availableSlotsService.getAvailableSlots({
input: getTestScheduleInput({ yesterdayDateString, plus5DateString }),
});

expect(schedule).not.toHaveTimeSlots(["04:00:00.000Z"], {
dateString: plus2DateString,
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ export class PrismaSelectedSlotRepository implements ISelectedSlotRepository {
}) {
return this.prismaClient.selectedSlots.findMany({
where: {
userId: { in: userIds },
OR: [
{ userId: { in: userIds } },
{ userId: -1 } // Include generic Round-Robin reservations
],
Comment on lines +63 to +66
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Do not pull every Round-Robin hold into unrelated availability checks.

Adding { userId: -1 } without scoping by event type makes this query return all Round-Robin reservations across the entire table. Downstream consumers (e.g. _getReservedSlotsAndCleanupExpired) now assume every returned row belongs to the current event type, so any other Round-Robin event that reserves the same timestamp instantly reduces capacity (or blocks bookings) for events whose hosts are completely unrelated. This regresses isolated teams: a booking for Team A at 10:00 with hosts {1,2} will prevent Team B with hosts {10,11} from reserving that same 10:00 slot because the global count trips the capacity guard.

We need to keep the fix (one reservation per slot) while still isolating capacity per host group. At minimum the query must restrict the userId: -1 branch to the relevant eventTypeId(s) so unrelated pools stay independent; otherwise every Round-Robin event competes for the same shared counter. Please tighten this filter (or carry eventTypeId context into the repository) before shipping.

🤖 Prompt for AI Agents
In packages/lib/server/repository/PrismaSelectedSlotRepository.ts around lines
63-66 the OR branch currently includes a bare { userId: -1 } which pulls every
Round-Robin reservation globally; narrow this by adding the relevant eventTypeId
condition so the Round-Robin branch only matches rows for the current event
type(s) (e.g. { AND: [{ userId: -1 }, { eventTypeId: currentEventTypeId }] }).
If the repository method does not yet accept eventTypeId, add it to the method
signature and pass it from callers (or otherwise supply the eventTypeId context)
so the query can include the eventTypeId filter while preserving the "one
reservation per slot" behavior.

releaseAt: { gt: currentTimeInUtc },
},
select: {
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/server/repository/dto/SelectedSlot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ import type { SelectedSlots } from "@calcom/prisma/client";

export type SelectedSlot = Pick<
SelectedSlots,
"id" | "uid" | "eventTypeId" | "slotUtcStartDate" | "slotUtcEndDate" | "releaseAt" | "isSeat"
"id" | "uid" | "eventTypeId" | "slotUtcStartDate" | "slotUtcEndDate" | "releaseAt" | "isSeat" | "userId"
>;
106 changes: 78 additions & 28 deletions packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,35 +76,85 @@ export const reserveSlotHandler = async ({ ctx, input }: ReserveSlotOptions) =>

if (eventType && shouldReserveSlot && !reservedBySomeoneElse && !_isDryRun) {
try {
await Promise.all(
// FIXME: In case of team event, users doesn't have assignees, those are in hosts. users just have the creator of the event which is wrong.
// Also, we must not block all the users' slots, we must use routedTeamMemberIds if set like we do in getSchedule.
// We could even improve it by identifying the next person being booked now that we have a queue of assignees.
eventType.users.map((user) =>
prisma.selectedSlots.upsert({
where: { selectedSlotUnique: { userId: user.id, slotUtcStartDate, slotUtcEndDate, uid } },
update: {
slotUtcStartDate,
slotUtcEndDate,
releaseAt,
eventTypeId,
},
create: {
userId: user.id,
eventTypeId,
slotUtcStartDate,
slotUtcEndDate,
uid,
releaseAt,
isSeat: eventType.seatsPerTimeSlot !== null,
},
})
)
);
} catch {
// Get the event type details to check scheduling type
const eventTypeDetails = await prisma.eventType.findUnique({
where: { id: eventTypeId },
select: { schedulingType: true },
});

if (eventTypeDetails?.schedulingType === "ROUND_ROBIN") {
// Atomic operation: Create reservation first, then check capacity
await prisma.selectedSlots.upsert({
where: { selectedSlotUnique: { userId: -1, slotUtcStartDate, slotUtcEndDate, uid } },
update: {
slotUtcStartDate,
slotUtcEndDate,
releaseAt,
eventTypeId,
},
create: {
userId: -1, // Generic reservation for Round-Robin
eventTypeId,
slotUtcStartDate,
slotUtcEndDate,
uid,
releaseAt,
isSeat: eventType.seatsPerTimeSlot !== null,
},
});

// Check capacity after creation to handle race conditions
const totalReservations = await prisma.selectedSlots.count({
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 15, 2025

Choose a reason for hiding this comment

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

Round-robin capacity counting is not scoped by eventTypeId, so reservations from other event types with the same time range will prematurely exhaust capacity for this event type.

Prompt for AI agents
Address the following comment on packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts at line 107:

<comment>Round-robin capacity counting is not scoped by eventTypeId, so reservations from other event types with the same time range will prematurely exhaust capacity for this event type.</comment>

<file context>
@@ -76,35 +76,85 @@ export const reserveSlotHandler = async ({ ctx, input }: ReserveSlotOptions) =&gt;
+        });
+
+        // Check capacity after creation to handle race conditions
+        const totalReservations = await prisma.selectedSlots.count({
+          where: {
+            slotUtcStartDate,
</file context>
Fix with Cubic

where: {
slotUtcStartDate,
slotUtcEndDate,
userId: -1,
releaseAt: { gt: new Date() },
},
});

if (totalReservations > eventType.users.length) {
// Rollback: Delete the reservation we just created
await prisma.selectedSlots.delete({
where: { selectedSlotUnique: { userId: -1, slotUtcStartDate, slotUtcEndDate, uid } },
});
throw new TRPCError({
message: "Slot is fully booked",
code: "BAD_REQUEST",
Comment on lines +107 to +123
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Round-Robin overbooking check needs to isolate by event type.

selectedSlots.count currently sums every Round-Robin hold (userId: -1) at the same timestamp, regardless of which event type created it. That means once some other team has two holds at 10:00, your team’s first hold immediately pushes totalReservations above eventType.users.length and throws “Slot is fully booked”, even though none of your hosts are taken. The repository change mirrors this and both together make unrelated Round-Robin events block each other globally.

Please scope the count to the relevant event type (or another discriminator tied to the actual host pool) so we only compare against the capacity that belongs to this event type’s hosts. Right now this breaks RR booking for any org running multiple independent RR event types.

🤖 Prompt for AI Agents
In packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts around lines
107-123, the selectedSlots.count and subsequent rollback delete are currently
counting all Round-Robin holds (userId: -1) at the same timestamp across event
types; restrict the count to this event type’s host pool by adding the
event-type discriminator to the where clause (e.g., eventTypeId or
eventType.uid) so you only compare holds belonging to this event type, and
include the same discriminator in the delete where to ensure the rollback
targets the exact reservation row you created.

});
}
} else {
// For other scheduling types, create reservations for all users
await Promise.all(
eventType.users.map((user) =>
prisma.selectedSlots.upsert({
where: { selectedSlotUnique: { userId: user.id, slotUtcStartDate, slotUtcEndDate, uid } },
update: {
slotUtcStartDate,
slotUtcEndDate,
releaseAt,
eventTypeId,
},
create: {
userId: user.id,
eventTypeId,
slotUtcStartDate,
slotUtcEndDate,
uid,
releaseAt,
isSeat: eventType.seatsPerTimeSlot !== null,
},
})
)
);
}
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
message: "Event type not found",
code: "NOT_FOUND",
message: "Failed to reserve slot",
code: "INTERNAL_SERVER_ERROR",
});
}
}
Expand Down
Loading
Loading