From 477f621825347c1998a7fa8c210c81baea5ea922 Mon Sep 17 00:00:00 2001 From: husniabad Date: Thu, 16 Oct 2025 21:50:31 +0300 Subject: [PATCH 1/2] fix: round-robin reserve per host capacity --- .../lib/getSchedule/selectedSlots.test.ts | 146 +++++++++++++++++- packages/features/redis/IRedisService.d.ts | 2 + .../PrismaSelectedSlotRepository.ts | 5 +- .../lib/server/repository/dto/SelectedSlot.ts | 2 +- .../viewer/slots/reserveSlot.handler.ts | 106 +++++++++---- .../trpc/server/routers/viewer/slots/util.ts | 99 ++++++++---- 6 files changed, 299 insertions(+), 61 deletions(-) diff --git a/apps/web/test/lib/getSchedule/selectedSlots.test.ts b/apps/web/test/lib/getSchedule/selectedSlots.test.ts index 6abecdddc3bd5f..01235489b4d47a 100644 --- a/apps/web/test/lib/getSchedule/selectedSlots.test.ts +++ b/apps/web/test/lib/getSchedule/selectedSlots.test.ts @@ -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, + }); + }); + }); +}); \ No newline at end of file diff --git a/packages/features/redis/IRedisService.d.ts b/packages/features/redis/IRedisService.d.ts index bfc97c2cba612e..0cf0c47f587a6a 100644 --- a/packages/features/redis/IRedisService.d.ts +++ b/packages/features/redis/IRedisService.d.ts @@ -3,6 +3,8 @@ export interface IRedisService { set: (key: string, value: TData, opts?: { ttl?: number }) => Promise<"OK" | TData | null>; + setex: (key: string, seconds: number, value: string) => Promise<"OK">; + expire: (key: string, seconds: number) => Promise<0 | 1>; lrange: (key: string, start: number, end: number) => Promise; diff --git a/packages/lib/server/repository/PrismaSelectedSlotRepository.ts b/packages/lib/server/repository/PrismaSelectedSlotRepository.ts index da0cff8905a3ec..e8d621459cedb9 100644 --- a/packages/lib/server/repository/PrismaSelectedSlotRepository.ts +++ b/packages/lib/server/repository/PrismaSelectedSlotRepository.ts @@ -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 + ], releaseAt: { gt: currentTimeInUtc }, }, select: { diff --git a/packages/lib/server/repository/dto/SelectedSlot.ts b/packages/lib/server/repository/dto/SelectedSlot.ts index d33120d9e88ee2..4950282bbc08d6 100644 --- a/packages/lib/server/repository/dto/SelectedSlot.ts +++ b/packages/lib/server/repository/dto/SelectedSlot.ts @@ -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" >; diff --git a/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts b/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts index ccd4f13539dcc0..f52163bfea2f09 100644 --- a/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts +++ b/packages/trpc/server/routers/viewer/slots/reserveSlot.handler.ts @@ -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({ + 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", + }); + } + } 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", }); } } diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 570181247ae12e..9dcc4425e54451 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -82,6 +82,7 @@ export interface IGetAvailableSlots { toUser?: IToUser | undefined; reason?: string | undefined; emoji?: string | undefined; + availableHosts?: number | undefined; }[] >; troubleshooter?: any; @@ -115,36 +116,28 @@ function withSlotsCache( func: (args: GetScheduleOptions) => Promise ) { return async (args: GetScheduleOptions): Promise => { - const cacheKey = `${JSON.stringify(args.input)}`; - let success = false; - let cachedResult: IGetAvailableSlots | null = null; - const startTime = process.hrtime(); + const { input, ctx } = args; + const bookerClientUid = ctx?.req?.cookies?.uid || 'anonymous'; + + const cacheKey = `slots:${input.eventTypeId || 'dynamic'}:${input.startTime}:${input.endTime}:${bookerClientUid}`; + try { - cachedResult = await redisClient.get(cacheKey); - success = true; - } catch (err: unknown) { - if (err instanceof Error && err.name === "TimeoutError") { - const endTime = process.hrtime(startTime); - log.error(`Redis request timed out after ${endTime[0]}${endTime[1] / 1e6}ms`); - } else { - throw err; + const cached = await redisClient.get(cacheKey); + if (cached) { + return JSON.parse(cached); } + } catch (error) { + log.warn('Failed to get cached slots result', { error, cacheKey }); } - - if (!success) { - // If the cache request fails, we proceed to call the function directly - return await func(args); - } - if (cachedResult) { - log.info("[CACHE HIT] Available slots", { cacheKey }); - return cachedResult; - } + const result = await func(args); - const ttl = parseInt(process.env.SLOTS_CACHE_TTL ?? "", 10) || DEFAULT_SLOTS_CACHE_TTL; - // we do not wait for the cache to complete setting; we fire and forget, and hope it'll finish. - // this is to already start responding to the client. - redisClient.set(cacheKey, result, { ttl }); - log.info("[CACHE MISS] Available slots", { cacheKey, ttl }); + + try { + await redisClient.set(cacheKey, JSON.stringify(result), { ttl: DEFAULT_SLOTS_CACHE_TTL }); + } catch (error) { + log.warn('Failed to cache slots result', { error, cacheKey }); + } + return result; }; } @@ -170,8 +163,15 @@ export class AvailableSlotsService { currentTimeInUtc, })) || []; - const slotsSelectedByOtherUsers = unexpiredSelectedSlots.filter((slot) => slot.uid !== bookerClientUid); - + // For capacity-based logic, we need ALL reservations, not just from other users + const slotsSelectedByOtherUsers = unexpiredSelectedSlots.filter((slot) => { + // For generic Round-Robin reservations (userId: -1), include ALL reservations for capacity calculation + if (slot.userId === -1) { + return true; + } + // For regular reservations, exclude current user's reservations + return slot.uid !== bookerClientUid; + }); await _cleanupExpiredSlots({ eventTypeId }); const reservedSlots = slotsSelectedByOtherUsers; @@ -1265,7 +1265,26 @@ export class AvailableSlotsService { } const busySlotsFromReservedSlots = reservedSlots.reduce((r, c) => { if (!c.isSeat) { - r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate }); + if (eventType.schedulingType === "ROUND_ROBIN") { + // For Round-Robin, count all reservations for this slot (including generic userId: -1) + const reservationsForSlot = reservedSlots.filter( + slot => slot.slotUtcStartDate.getTime() === c.slotUtcStartDate.getTime() && + slot.slotUtcEndDate.getTime() === c.slotUtcEndDate.getTime() && + !slot.isSeat && + (slot.userId === -1 || usersWithCredentials.some(u => u.id === slot.userId)) + ); + + // Block slot if capacity is exceeded + if (reservationsForSlot.length >= usersWithCredentials.length) { + r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate }); + } + } else if (eventType.schedulingType === "COLLECTIVE") { + // For Collective, any reservation blocks the slot (all hosts must be available) + r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate }); + } else { + // For regular events, any reservation blocks the slot + r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate }); + } } return r; }, []); @@ -1323,7 +1342,7 @@ export class AvailableSlotsService { return availableTimeSlots.reduce( ( - r: Record, + r: Record, { time, ...passThroughProps } ) => { // This used to be _time.tz(input.timeZone) but Dayjs tz() is slow. @@ -1337,10 +1356,30 @@ export class AvailableSlotsService { } const existingBooking = currentSeatsMap.get(timeISO); + + // Calculate available hosts for this time slot + let availableHosts = usersWithCredentials.length; + if (eventType.schedulingType === "ROUND_ROBIN") { + const reservationsForSlot = reservedSlots.filter( + slot => slot.slotUtcStartDate.getTime() === time.toDate().getTime() && + !slot.isSeat && + (slot.userId === -1 || usersWithCredentials.some(u => u.id === slot.userId)) + ); + availableHosts = Math.max(0, usersWithCredentials.length - reservationsForSlot.length); + + + } else if (eventType.schedulingType === "COLLECTIVE") { + // For Collective, if any reservation exists, no hosts are available + const hasReservation = reservedSlots.some( + slot => slot.slotUtcStartDate.getTime() === time.toDate().getTime() && !slot.isSeat + ); + availableHosts = hasReservation ? 0 : usersWithCredentials.length; + } r[dateString].push({ ...passThroughProps, time: timeISO, + availableHosts, ...(existingBooking && { attendees: existingBooking.attendees, bookingUid: existingBooking.uid, From 76283926d872f6b162d98530d0d1739108a43714 Mon Sep 17 00:00:00 2001 From: husniabad Date: Thu, 16 Oct 2025 22:18:44 +0300 Subject: [PATCH 2/2] fix types and tests --- packages/features/redis/IRedisService.d.ts | 2 -- packages/trpc/server/routers/viewer/slots/util.ts | 8 ++++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/features/redis/IRedisService.d.ts b/packages/features/redis/IRedisService.d.ts index 0cf0c47f587a6a..bfc97c2cba612e 100644 --- a/packages/features/redis/IRedisService.d.ts +++ b/packages/features/redis/IRedisService.d.ts @@ -3,8 +3,6 @@ export interface IRedisService { set: (key: string, value: TData, opts?: { ttl?: number }) => Promise<"OK" | TData | null>; - setex: (key: string, seconds: number, value: string) => Promise<"OK">; - expire: (key: string, seconds: number) => Promise<0 | 1>; lrange: (key: string, start: number, end: number) => Promise; diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 9dcc4425e54451..85cfb7ef19c3db 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -1265,7 +1265,7 @@ export class AvailableSlotsService { } const busySlotsFromReservedSlots = reservedSlots.reduce((r, c) => { if (!c.isSeat) { - if (eventType.schedulingType === "ROUND_ROBIN") { + if (eventType?.schedulingType === "ROUND_ROBIN") { // For Round-Robin, count all reservations for this slot (including generic userId: -1) const reservationsForSlot = reservedSlots.filter( slot => slot.slotUtcStartDate.getTime() === c.slotUtcStartDate.getTime() && @@ -1278,7 +1278,7 @@ export class AvailableSlotsService { if (reservationsForSlot.length >= usersWithCredentials.length) { r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate }); } - } else if (eventType.schedulingType === "COLLECTIVE") { + } else if (eventType?.schedulingType === "COLLECTIVE") { // For Collective, any reservation blocks the slot (all hosts must be available) r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate }); } else { @@ -1359,7 +1359,7 @@ export class AvailableSlotsService { // Calculate available hosts for this time slot let availableHosts = usersWithCredentials.length; - if (eventType.schedulingType === "ROUND_ROBIN") { + if (eventType?.schedulingType === "ROUND_ROBIN") { const reservationsForSlot = reservedSlots.filter( slot => slot.slotUtcStartDate.getTime() === time.toDate().getTime() && !slot.isSeat && @@ -1368,7 +1368,7 @@ export class AvailableSlotsService { availableHosts = Math.max(0, usersWithCredentials.length - reservationsForSlot.length); - } else if (eventType.schedulingType === "COLLECTIVE") { + } else if (eventType?.schedulingType === "COLLECTIVE") { // For Collective, if any reservation exists, no hosts are available const hasReservation = reservedSlots.some( slot => slot.slotUtcStartDate.getTime() === time.toDate().getTime() && !slot.isSeat