From c3634b3abad4e945dd3e65c1a9fb614a8e8a2b5d Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:13:42 +0300 Subject: [PATCH] refactor: getUserAvailability into service with DI (#22881) * refactor: getUserAvailability into service with DI * chore: bump platform libs * disable bull queue in e2e for bookings * chore: bump platform libs * chore: bump platform libs * fix: should update event type bookingFields test --------- Co-authored-by: Alex van Andel --- apps/api/v1/pages/api/availability/_get.ts | 7 +- apps/api/v2/package.json | 2 +- .../event-types.controller.e2e-spec.ts | 5 +- .../src/lib/modules/available-slots.module.ts | 2 + .../lib/services/available-slots.service.ts | 2 + .../lib/services/user-availability.service.ts | 27 + .../billing/services/billing.service.ts | 8 + .../handleNewBooking/ensureAvailableUsers.ts | 5 +- packages/lib/di/containers/available-slots.ts | 3 +- .../di/containers/get-user-availability.ts | 23 + packages/lib/di/modules/available-slots.ts | 1 + .../lib/di/modules/get-user-availability.ts | 11 + packages/lib/di/tokens.ts | 2 + packages/lib/getUserAvailability.ts | 1077 +++++++---------- .../server/getBusyTimesFromLimits.ts | 9 +- packages/lib/server/i18n.ts | 37 +- packages/lib/server/repository/booking.ts | 22 + .../server/repository/eventTypeRepository.ts | 89 ++ packages/lib/server/repository/ooo.ts | 67 + packages/platform/libraries/schedules.ts | 1 + .../viewer/availability/user.handler.ts | 5 +- .../trpc/server/routers/viewer/slots/util.ts | 15 +- .../teams/getMemberAvailability.handler.ts | 5 +- yarn.lock | 10 +- 24 files changed, 784 insertions(+), 651 deletions(-) create mode 100644 apps/api/v2/src/lib/services/user-availability.service.ts create mode 100644 packages/lib/di/containers/get-user-availability.ts create mode 100644 packages/lib/di/modules/get-user-availability.ts diff --git a/apps/api/v1/pages/api/availability/_get.ts b/apps/api/v1/pages/api/availability/_get.ts index 28567dc387..572bfb06f9 100644 --- a/apps/api/v1/pages/api/availability/_get.ts +++ b/apps/api/v1/pages/api/availability/_get.ts @@ -1,7 +1,7 @@ import type { NextApiRequest } from "next"; import { z } from "zod"; -import { getUserAvailability } from "@calcom/lib/getUserAvailability"; +import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; import prisma from "@calcom/prisma"; @@ -191,8 +191,9 @@ const availabilitySchema = z async function handler(req: NextApiRequest) { const { isSystemWideAdmin, userId: reqUserId } = req; const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query); + const userAvailabilityService = getUserAvailabilityService() if (!teamId) - return getUserAvailability({ + return userAvailabilityService.getUserAvailability({ username, dateFrom, dateTo, @@ -230,7 +231,7 @@ async function handler(req: NextApiRequest) { const availabilities = members.map(async (user) => { return { userId: user.id, - availability: await getUserAvailability({ + availability: await userAvailabilityService.getUserAvailability({ userId: user.id, dateFrom, dateTo, diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index b2ed19e715..dff4f3de17 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -38,7 +38,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.276", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.283", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", "@calcom/prisma": "*", diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts index 099526766b..4b03003880 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts @@ -290,7 +290,10 @@ describe("Event types Endpoints", () => { expect(responseBookingFields).toBeDefined(); // note(Lauris): response bookingFields are already existing default bookingFields + the new one const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName); - expect(responseBookingField).toEqual(bookingFields[0]); + const fields = responseBookingField + //@ts-ignore + delete fields.labelAsSafeHtml + expect(fields).toEqual(bookingFields[0]); eventType.bookingFields = responseBookingFields; }); }); diff --git a/apps/api/v2/src/lib/modules/available-slots.module.ts b/apps/api/v2/src/lib/modules/available-slots.module.ts index 7d16984cc5..33c6b42797 100644 --- a/apps/api/v2/src/lib/modules/available-slots.module.ts +++ b/apps/api/v2/src/lib/modules/available-slots.module.ts @@ -12,6 +12,7 @@ import { CacheService } from "@/lib/services/cache.service"; import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { Module } from "@nestjs/common"; +import { UserAvailabilityService } from "@/lib/services/user-availability.service"; @Module({ imports: [PrismaModule], @@ -28,6 +29,7 @@ import { Module } from "@nestjs/common"; CheckBookingLimitsService, CacheService, AvailableSlotsService, + UserAvailabilityService ], exports: [AvailableSlotsService], }) diff --git a/apps/api/v2/src/lib/services/available-slots.service.ts b/apps/api/v2/src/lib/services/available-slots.service.ts index 2ced88dbb9..27582906f2 100644 --- a/apps/api/v2/src/lib/services/available-slots.service.ts +++ b/apps/api/v2/src/lib/services/available-slots.service.ts @@ -12,6 +12,7 @@ import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.s import { Injectable } from "@nestjs/common"; import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots"; +import { UserAvailabilityService } from "./user-availability.service"; @Injectable() export class AvailableSlotsService extends BaseAvailableSlotsService { @@ -37,6 +38,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService { userRepo: userRepository, checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any, cacheService: new CacheService(featuresRepository), + userAvailabilityService: new UserAvailabilityService(oooRepoDependency, bookingRepository, eventTypeRepository) }); } } diff --git a/apps/api/v2/src/lib/services/user-availability.service.ts b/apps/api/v2/src/lib/services/user-availability.service.ts new file mode 100644 index 0000000000..b735b4a2a3 --- /dev/null +++ b/apps/api/v2/src/lib/services/user-availability.service.ts @@ -0,0 +1,27 @@ +import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; +import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository"; + +import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository"; + +import { Injectable } from "@nestjs/common"; + +import { UserAvailabilityService as BaseUserAvailabilityService } from "@calcom/platform-libraries/schedules"; + +@Injectable() +export class UserAvailabilityService extends BaseUserAvailabilityService { + constructor( + oooRepoDependency: PrismaOOORepository, + bookingRepository: PrismaBookingRepository, + eventTypeRepository: PrismaEventTypeRepository, + + + ) { + super({ + oooRepo: oooRepoDependency, + + bookingRepo: bookingRepository, + + eventTypeRepo: eventTypeRepository, + }); + } +} diff --git a/apps/api/v2/src/modules/billing/services/billing.service.ts b/apps/api/v2/src/modules/billing/services/billing.service.ts index 4f7057b4ca..181e7fd1f1 100644 --- a/apps/api/v2/src/modules/billing/services/billing.service.ts +++ b/apps/api/v2/src/modules/billing/services/billing.service.ts @@ -403,6 +403,10 @@ export class BillingService implements OnModuleDestroy { fromReschedule?: string | null; } ) { + + if (this.configService.get("e2e")) { + return true; + } const { uid, startTime, fromReschedule } = booking; const delay = startTime.getTime() - Date.now(); @@ -427,6 +431,9 @@ export class BillingService implements OnModuleDestroy { * Removing an attendee from a booking does not cancel the usage increment job. */ async cancelUsageByBookingUid(bookingUid: string) { + if (this.configService.get("e2e")) { + return true; + } const job = await this.billingQueue.getJob(`increment-${bookingUid}`); if (job) { await job.remove(); @@ -458,6 +465,7 @@ export class BillingService implements OnModuleDestroy { async onModuleDestroy() { try { + await this.billingQueue.close(); } catch (err) { this.logger.error(err); diff --git a/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts b/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts index 439257c3a0..91ed291aa8 100644 --- a/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts @@ -6,7 +6,7 @@ import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker import { buildDateRanges } from "@calcom/lib/date-ranges"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { getBusyTimesForLimitChecks } from "@calcom/lib/getBusyTimes"; -import { getUsersAvailability } from "@calcom/lib/getUserAvailability"; +import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability"; import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits"; import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits"; import { getPiiFreeUser } from "@calcom/lib/piiFreeData"; @@ -62,6 +62,7 @@ const _ensureAvailableUsers = async ( shouldServeCache?: boolean // ReturnType hint of at least one IsFixedAwareUser, as it's made sure at least one entry exists ): Promise<[IsFixedAwareUser, ...IsFixedAwareUser[]]> => { + const userAvailabilityService = getUserAvailabilityService() const availableUsers: IsFixedAwareUser[] = []; const startDateTimeUtc = getDateTimeInUtc(input.dateFrom, input.timeZone); @@ -86,7 +87,7 @@ const _ensureAvailableUsers = async ( }) : []; - const usersAvailability = await getUsersAvailability({ + const usersAvailability = await userAvailabilityService.getUsersAvailability({ users: eventType.users, query: { ...input, diff --git a/packages/lib/di/containers/available-slots.ts b/packages/lib/di/containers/available-slots.ts index 55ff31fc60..30dd7bab64 100644 --- a/packages/lib/di/containers/available-slots.ts +++ b/packages/lib/di/containers/available-slots.ts @@ -16,6 +16,7 @@ import { scheduleRepositoryModule } from "../modules/schedule"; import { selectedSlotsRepositoryModule } from "../modules/selectedSlots"; import { teamRepositoryModule } from "../modules/team"; import { userRepositoryModule } from "../modules/user"; +import { getUserAvailabilityModule } from "../modules/get-user-availability"; const container = createContainer(); container.load(DI_TOKENS.PRISMA_MODULE, prismaModule); @@ -31,7 +32,7 @@ container.load(DI_TOKENS.FEATURES_REPOSITORY_MODULE, featuresRepositoryModule); container.load(DI_TOKENS.CACHE_SERVICE_MODULE, cacheModule); container.load(DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE_MODULE, checkBookingLimitsModule); container.load(DI_TOKENS.AVAILABLE_SLOTS_SERVICE_MODULE, availableSlotsModule); - +container.load(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE_MODULE, getUserAvailabilityModule) export function getAvailableSlotsService() { return container.get(DI_TOKENS.AVAILABLE_SLOTS_SERVICE); } diff --git a/packages/lib/di/containers/get-user-availability.ts b/packages/lib/di/containers/get-user-availability.ts new file mode 100644 index 0000000000..c8e047500c --- /dev/null +++ b/packages/lib/di/containers/get-user-availability.ts @@ -0,0 +1,23 @@ +import { createContainer } from "@evyweb/ioctopus"; + +import { DI_TOKENS } from "@calcom/lib/di/tokens"; +import { prismaModule } from "@calcom/prisma/prisma.module"; + +import { getUserAvailabilityModule } from "../modules/get-user-availability"; +import { bookingRepositoryModule } from "../modules/booking"; + +import { eventTypeRepositoryModule } from "../modules/eventType"; + +import { oooRepositoryModule } from "../modules/ooo"; +import { UserAvailabilityService } from "../../getUserAvailability"; + +const container = createContainer(); +container.load(DI_TOKENS.PRISMA_MODULE, prismaModule); +container.load(DI_TOKENS.OOO_REPOSITORY_MODULE, oooRepositoryModule); +container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule); +container.load(DI_TOKENS.EVENT_TYPE_REPOSITORY_MODULE, eventTypeRepositoryModule); +container.load(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE_MODULE, getUserAvailabilityModule); + +export function getUserAvailabilityService() { + return container.get(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE); +} diff --git a/packages/lib/di/modules/available-slots.ts b/packages/lib/di/modules/available-slots.ts index c9dd4012e6..15bd79a17b 100644 --- a/packages/lib/di/modules/available-slots.ts +++ b/packages/lib/di/modules/available-slots.ts @@ -17,4 +17,5 @@ availableSlotsModule.bind(DI_TOKENS.AVAILABLE_SLOTS_SERVICE).toClass(AvailableSl routingFormResponseRepo: DI_TOKENS.ROUTING_FORM_RESPONSE_REPOSITORY, cacheService: DI_TOKENS.CACHE_SERVICE, checkBookingLimitsService: DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE, + userAvailabilityService: DI_TOKENS.GET_USER_AVAILABILITY_SERVICE } satisfies Record); diff --git a/packages/lib/di/modules/get-user-availability.ts b/packages/lib/di/modules/get-user-availability.ts new file mode 100644 index 0000000000..1db28db1a4 --- /dev/null +++ b/packages/lib/di/modules/get-user-availability.ts @@ -0,0 +1,11 @@ +import { createModule } from "@evyweb/ioctopus"; + +import { DI_TOKENS } from "../tokens"; +import { IUserAvailabilityService, UserAvailabilityService } from "../../getUserAvailability"; + +export const getUserAvailabilityModule = createModule(); +getUserAvailabilityModule.bind(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE).toClass(UserAvailabilityService, { + oooRepo: DI_TOKENS.OOO_REPOSITORY, + bookingRepo: DI_TOKENS.BOOKING_REPOSITORY, + eventTypeRepo: DI_TOKENS.EVENT_TYPE_REPOSITORY, +} satisfies Record); diff --git a/packages/lib/di/tokens.ts b/packages/lib/di/tokens.ts index 0f56cb9e5d..d124b16219 100644 --- a/packages/lib/di/tokens.ts +++ b/packages/lib/di/tokens.ts @@ -32,4 +32,6 @@ export const DI_TOKENS = { CHECK_BOOKING_LIMITS_SERVICE_MODULE: Symbol("CheckBookingLimitsServiceModule"), CHECK_BOOKING_AND_DURATION_LIMITS_SERVICE: Symbol("CheckBookingAndDurationLimitsService"), CHECK_BOOKING_AND_DURATION_LIMITS_SERVICE_MODULE: Symbol("CheckBookingAndDurationLimitsServiceModule"), + GET_USER_AVAILABILITY_SERVICE: Symbol("GetUserAvailabilityService"), + GET_USER_AVAILABILITY_SERVICE_MODULE: Symbol("GetUserAvailabilityModule"), }; diff --git a/packages/lib/getUserAvailability.ts b/packages/lib/getUserAvailability.ts index 393bdc4807..107fcd70ad 100644 --- a/packages/lib/getUserAvailability.ts +++ b/packages/lib/getUserAvailability.ts @@ -8,6 +8,7 @@ import type { } from "@prisma/client"; import * as Sentry from "@sentry/nextjs"; import { z } from "zod"; +import type { BookingRepository } from "@calcom/lib/server/repository/booking"; import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; @@ -27,15 +28,14 @@ import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { findUsersForAvailabilityCheck } from "@calcom/lib/server/findUsersForAvailabilityCheck"; import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository"; -import prisma from "@calcom/prisma"; import { SchedulingType } from "@calcom/prisma/enums"; -import { BookingStatus } from "@calcom/prisma/enums"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { EventBusyDetails, IntervalLimitUnit } from "@calcom/types/Calendar"; import type { TimeRange } from "@calcom/types/schedule"; import { getBusyTimes } from "./getBusyTimes"; import { withReporting } from "./sentryWrapper"; +import type { PrismaOOORepository } from "@calcom/lib/server/repository/ooo"; const log = logger.getSubLogger({ prefix: ["getUserAvailability"] }); const availabilitySchema = z @@ -55,106 +55,11 @@ const availabilitySchema = z }) .refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in."); -const _getEventType = async (id: number) => { - const eventType = await prisma.eventType.findUnique({ - where: { id }, - select: { - id: true, - seatsPerTimeSlot: true, - bookingLimits: true, - useEventLevelSelectedCalendars: true, - parent: { - select: { - team: { - select: { - id: true, - bookingLimits: true, - includeManagedEventsInLimits: true, - }, - }, - }, - }, - team: { - select: { - id: true, - bookingLimits: true, - includeManagedEventsInLimits: true, - }, - }, - hosts: { - select: { - user: { - select: { - email: true, - id: true, - }, - }, - schedule: { - select: { - availability: { - select: { - date: true, - startTime: true, - endTime: true, - days: true, - }, - }, - timeZone: true, - id: true, - }, - }, - }, - }, - durationLimits: true, - assignAllTeamMembers: true, - schedulingType: true, - timeZone: true, - length: true, - metadata: true, - schedule: { - select: { - id: true, - availability: { - select: { - days: true, - date: true, - startTime: true, - endTime: true, - }, - }, - timeZone: true, - }, - }, - availability: { - select: { - startTime: true, - endTime: true, - days: true, - date: true, - }, - }, - }, - }); - if (!eventType) { - return eventType; - } - return { - ...eventType, - metadata: EventTypeMetaDataSchema.parse(eventType.metadata), - }; -}; +export type EventType = Awaited< + ReturnType<(typeof UserAvailabilityService)["prototype"]["_getEventType"]> +>; -export type EventType = Awaited>; - -export const getEventType = withReporting(_getEventType, "getEventType"); - -const _getUser = async (where: Prisma.UserWhereInput) => { - return findUsersForAvailabilityCheck({ where }); -}; - -type GetUser = Awaited>; - -const getUser = withReporting(_getUser, "getUser"); +type GetUser = Awaited>; export type GetUserAvailabilityInitialData = { user?: GetUser; @@ -207,445 +112,13 @@ type GetUserAvailabilityQuery = { shouldServeCache?: boolean; }; -const _getCurrentSeats = async ( - eventType: { - id?: number; - schedulingType?: SchedulingType | null; - hosts?: { - user: { - email: string; - }; - }[]; - }, - dateFrom: Dayjs, - dateTo: Dayjs -) => { - const { schedulingType, hosts, id } = eventType; - const hostEmails = hosts?.map((host) => host.user.email); - const isTeamEvent = - schedulingType === SchedulingType.MANAGED || - schedulingType === SchedulingType.ROUND_ROBIN || - schedulingType === SchedulingType.COLLECTIVE; +export type CurrentSeats = Awaited< + ReturnType<(typeof UserAvailabilityService)["prototype"]["_getCurrentSeats"]> +>; - const bookings = await prisma.booking.findMany({ - where: { - eventTypeId: id, - startTime: { - gte: dateFrom.format(), - lte: dateTo.format(), - }, - status: BookingStatus.ACCEPTED, - }, - select: { - uid: true, - startTime: true, - attendees: { - select: { - email: true, - }, - }, - }, - }); - - return bookings.map((booking) => { - const attendees = isTeamEvent - ? booking.attendees.filter((attendee) => !hostEmails?.includes(attendee.email)) - : booking.attendees; - - return { - uid: booking.uid, - startTime: booking.startTime, - _count: { - attendees: attendees.length, - }, - }; - }); -}; - -export type CurrentSeats = Awaited>; - -export const getCurrentSeats = withReporting(_getCurrentSeats, "getCurrentSeats"); - -export type GetUserAvailabilityResult = Awaited>; - -/** This should be called getUsersWorkingHoursAndBusySlots (...and remaining seats, and final timezone) */ -const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseAndEverythingElse( - query: GetUserAvailabilityQuery, - initialData?: GetUserAvailabilityInitialData -) { - const { - username, - userId, - dateFrom, - dateTo, - eventTypeId, - afterEventBuffer, - beforeEventBuffer, - duration, - returnDateOverrides, - bypassBusyCalendarTimes = false, - shouldServeCache, - } = availabilitySchema.parse(query); - - log.debug( - `EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - Called with: ${safeStringify({ - query, - })}` - ); - - if (!dateFrom.isValid() || !dateTo.isValid()) { - throw new HttpError({ statusCode: 400, message: "Invalid time range given." }); - } - - const where: Prisma.UserWhereInput = {}; - if (username) where.username = username; - if (userId) where.id = userId; - - const user = initialData?.user || (await getUser(where)); - - if (!user) { - throw new HttpError({ statusCode: 404, message: "No user found in getUserAvailability" }); - } - - let eventType: EventType | null = initialData?.eventType || null; - if (!eventType && eventTypeId) eventType = await getEventType(eventTypeId); - - /* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab - current bookings with a seats event type and display them on the calendar, even if they are full */ - let currentSeats: CurrentSeats | null = initialData?.currentSeats || null; - if (!currentSeats && eventType?.seatsPerTimeSlot) { - currentSeats = await getCurrentSeats(eventType, dateFrom, dateTo); - } - - const userSchedule = user.schedules.filter( - (schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId - )[0]; - - const hostSchedule = eventType?.hosts?.find((host) => host.user.id === user.id)?.schedule; - - // TODO: It uses default timezone of user. Should we use timezone of team ? - const fallbackTimezoneIfScheduleIsMissing = eventType?.timeZone || user.timeZone; - - const fallbackSchedule = { - availability: [ - { - startTime: new Date("1970-01-01T09:00:00Z"), - endTime: new Date("1970-01-01T17:00:00Z"), - days: [1, 2, 3, 4, 5], // Monday to Friday - date: null, - }, - ], - id: 0, - - timeZone: fallbackTimezoneIfScheduleIsMissing, - }; - - const schedule = - (eventType?.schedule ? eventType.schedule : hostSchedule ? hostSchedule : userSchedule) ?? - fallbackSchedule; - const timeZone = schedule?.timeZone || fallbackTimezoneIfScheduleIsMissing; - - const bookingLimits = - eventType?.bookingLimits && - typeof eventType.bookingLimits === "object" && - Object.keys(eventType.bookingLimits).length > 0 - ? parseBookingLimit(eventType.bookingLimits) - : null; - - const durationLimits = - eventType?.durationLimits && - typeof eventType.durationLimits === "object" && - Object.keys(eventType.durationLimits).length > 0 - ? parseDurationLimit(eventType.durationLimits) - : null; - - let busyTimesFromLimits: EventBusyDetails[] = []; - - if (initialData?.busyTimesFromLimits && initialData?.eventTypeForLimits) { - busyTimesFromLimits = initialData.busyTimesFromLimits.get(user.id) || []; - } else if (eventType && (bookingLimits || durationLimits)) { - // Fall back to individual query if not available in initialData - busyTimesFromLimits = await getBusyTimesFromLimits( - bookingLimits, - durationLimits, - dateFrom.tz(timeZone), - dateTo.tz(timeZone), - duration, - eventType, - initialData?.busyTimesFromLimitsBookings ?? [], - timeZone, - initialData?.rescheduleUid ?? undefined - ); - } - - const teamForBookingLimits = - initialData?.teamForBookingLimits ?? - eventType?.team ?? - (eventType?.parent?.team?.includeManagedEventsInLimits ? eventType?.parent?.team : null); - - const teamBookingLimits = parseBookingLimit(teamForBookingLimits?.bookingLimits); - - let busyTimesFromTeamLimits: EventBusyDetails[] = []; - - if (initialData?.teamBookingLimits && teamForBookingLimits) { - busyTimesFromTeamLimits = initialData.teamBookingLimits.get(user.id) || []; - } else if (teamForBookingLimits && teamBookingLimits) { - // Fall back to individual query if not available in initialData - busyTimesFromTeamLimits = await getBusyTimesFromTeamLimits( - user, - teamBookingLimits, - dateFrom.tz(timeZone), - dateTo.tz(timeZone), - teamForBookingLimits.id, - teamForBookingLimits.includeManagedEventsInLimits, - timeZone, - initialData?.rescheduleUid ?? undefined - ); - } - - // TODO: only query what we need after applying limits (shrink date range) - const getBusyTimesStart = dateFrom.toISOString(); - const getBusyTimesEnd = dateTo.toISOString(); - - const selectedCalendars = eventType?.useEventLevelSelectedCalendars - ? EventTypeRepository.getSelectedCalendarsFromUser({ user, eventTypeId: eventType.id }) - : user.userLevelSelectedCalendars; - - let busyTimes = []; - try { - busyTimes = await getBusyTimes({ - credentials: user.credentials, - startTime: getBusyTimesStart, - endTime: getBusyTimesEnd, - eventTypeId, - userId: user.id, - userEmail: user.email, - username: `${user.username}`, - beforeEventBuffer, - afterEventBuffer, - selectedCalendars, - seatedEvent: !!eventType?.seatsPerTimeSlot, - rescheduleUid: initialData?.rescheduleUid || null, - duration, - currentBookings: initialData?.currentBookings, - bypassBusyCalendarTimes, - shouldServeCache, - }); - } catch (error) { - log.error(`Error fetching busy times for user ${username}:`, error); - return { - busy: [], - timeZone, - dateRanges: [], - oooExcludedDateRanges: [], - workingHours: [], - dateOverrides: [], - currentSeats: [], - datesOutOfOffice: undefined, - }; - } - - const detailedBusyTimes: EventBusyDetails[] = [ - ...busyTimes.map((a) => ({ - ...a, - start: dayjs(a.start).toISOString(), - end: dayjs(a.end).toISOString(), - title: a.title, - source: query.withSource ? a.source : undefined, - })), - ...busyTimesFromLimits, - ...busyTimesFromTeamLimits, - ]; - - const isDefaultSchedule = userSchedule && userSchedule.id === schedule?.id; - - log.debug( - `EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - usingSchedule: ${safeStringify({ - chosenSchedule: schedule, - eventTypeSchedule: eventType?.schedule, - userSchedule: userSchedule, - hostSchedule: hostSchedule, - })}` - ); - - if ( - !(schedule?.availability || (eventType?.availability.length ? eventType.availability : user.availability)) - ) { - throw new HttpError({ statusCode: 400, message: ErrorCode.AvailabilityNotFoundInSchedule }); - } - - const availability = ( - schedule?.availability || (eventType?.availability.length ? eventType.availability : user.availability) - ).map((a) => ({ - ...a, - userId: user.id, - })); - - const workingHours = getWorkingHours({ timeZone }, availability); - - const dateOverrides: TimeRange[] = []; - // NOTE: getSchedule is currently calling this function for every user in a team event - // but not using these values at all, wasting CPU. Adding this check here temporarily to avoid a larger refactor - // since other callers do using this data. - if (returnDateOverrides) { - const calculateDateOverridesSpan = Sentry.startInactiveSpan({ name: "calculateDateOverrides" }); - const availabilityWithDates = availability.filter((availability) => !!availability.date); - - for (let i = 0; i < availabilityWithDates.length; i++) { - const override = availabilityWithDates[i]; - const startTime = dayjs.utc(override.startTime); - const endTime = dayjs.utc(override.endTime); - const overrideStartDate = dayjs.utc(override.date).hour(startTime.hour()).minute(startTime.minute()); - const overrideEndDate = dayjs.utc(override.date).hour(endTime.hour()).minute(endTime.minute()); - if ( - overrideStartDate.isBetween(dateFrom, dateTo, null, "[]") || - overrideEndDate.isBetween(dateFrom, dateTo, null, "[]") - ) { - dateOverrides.push({ - start: overrideStartDate.toDate(), - end: overrideEndDate.toDate(), - }); - } - } - - calculateDateOverridesSpan.end(); - } - - const outOfOfficeDays = - initialData?.outOfOfficeDays ?? - (await prisma.outOfOfficeEntry.findMany({ - where: { - userId: user.id, - OR: [ - // outside of range - // (start <= 'dateTo' AND end >= 'dateFrom') - { - start: { - lte: dateTo.toISOString(), - }, - end: { - gte: dateFrom.toISOString(), - }, - }, - // start is between dateFrom and dateTo but end is outside of range - // (start <= 'dateTo' AND end >= 'dateTo') - { - start: { - lte: dateTo.toISOString(), - }, - - end: { - gte: dateTo.toISOString(), - }, - }, - // end is between dateFrom and dateTo but start is outside of range - // (start <= 'dateFrom' OR end <= 'dateTo') - { - start: { - lte: dateFrom.toISOString(), - }, - - end: { - lte: dateTo.toISOString(), - }, - }, - ], - }, - select: { - id: true, - start: true, - end: true, - user: { - select: { - id: true, - name: true, - }, - }, - toUser: { - select: { - id: true, - username: true, - name: true, - }, - }, - reason: { - select: { - id: true, - emoji: true, - reason: true, - }, - }, - }, - })); - - const datesOutOfOffice: IOutOfOfficeData = calculateOutOfOfficeRanges(outOfOfficeDays, availability); - - const { dateRanges, oooExcludedDateRanges } = buildDateRanges({ - dateFrom, - dateTo, - availability, - timeZone, - travelSchedules: isDefaultSchedule - ? user.travelSchedules.map((schedule) => { - return { - startDate: dayjs(schedule.startDate), - endDate: schedule.endDate ? dayjs(schedule.endDate) : undefined, - timeZone: schedule.timeZone, - }; - }) - : [], - outOfOffice: datesOutOfOffice, - }); - - const formattedBusyTimes = detailedBusyTimes.map((busy) => ({ - start: dayjs(busy.start), - end: dayjs(busy.end), - })); - - const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes); - const dateRangesInWhichUserIsAvailableWithoutOOO = subtract(oooExcludedDateRanges, formattedBusyTimes); - - const result = { - busy: detailedBusyTimes, - timeZone, - dateRanges: dateRangesInWhichUserIsAvailable, - oooExcludedDateRanges: dateRangesInWhichUserIsAvailableWithoutOOO, - workingHours, - dateOverrides, - currentSeats, - datesOutOfOffice, - }; - - log.debug( - `EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - Result: ${safeStringify(result)}` - ); - - return result; -}; - -export const getUserAvailability = withReporting(_getUserAvailability, "getUserAvailability"); - -const _getPeriodStartDatesBetween = ( - dateFrom: Dayjs, - dateTo: Dayjs, - period: IntervalLimitUnit, - timeZone?: string -): Dayjs[] => { - const dates = []; - let startDate = timeZone ? dayjs(dateFrom).tz(timeZone).startOf(period) : dayjs(dateFrom).startOf(period); - const endDate = timeZone ? dayjs(dateTo).tz(timeZone).endOf(period) : dayjs(dateTo).endOf(period); - - while (startDate.isBefore(endDate)) { - dates.push(startDate); - startDate = startDate.add(1, period); - } - return dates; -}; - -export const getPeriodStartDatesBetween = withReporting( - _getPeriodStartDatesBetween, - "getPeriodStartDatesBetween" -); +export type GetUserAvailabilityResult = Awaited< + ReturnType<(typeof UserAvailabilityService)["prototype"]["_getUserAvailability"]> +>; interface GetUserAvailabilityParamsDTO { availability: (DateOverride | WorkingHours)[]; @@ -671,50 +144,6 @@ export interface IOutOfOfficeData { }; } -const calculateOutOfOfficeRanges = ( - outOfOfficeDays: GetUserAvailabilityInitialData["outOfOfficeDays"], - availability: GetUserAvailabilityParamsDTO["availability"] -): IOutOfOfficeData => { - if (!outOfOfficeDays || outOfOfficeDays.length === 0) { - return {}; - } - - return outOfOfficeDays.reduce((acc: IOutOfOfficeData, { start, end, toUser, user, reason }) => { - // here we should use startDate or today if start is before today - // consider timezone in start and end date range - const startDateRange = dayjs(start).utc().isBefore(dayjs().startOf("day").utc()) - ? dayjs().utc().startOf("day") - : dayjs(start).utc().startOf("day"); - - // get number of day in the week and see if it's on the availability - const flattenDays = Array.from(new Set(availability.flatMap((a) => ("days" in a ? a.days : [])))).sort( - (a, b) => a - b - ); - - const endDateRange = dayjs(end).utc().endOf("day"); - - for (let date = startDateRange; date.isBefore(endDateRange); date = date.add(1, "day")) { - const dayNumberOnWeek = date.day(); - - if (!flattenDays?.includes(dayNumberOnWeek)) { - continue; // Skip to the next iteration if day not found in flattenDays - } - - acc[date.format("YYYY-MM-DD")] = { - // @TODO: would be good having start and end availability time here, but for now should be good - // you can obtain that from user availability defined outside of here - fromUser: { id: user.id, displayName: user.name }, - // optional chaining destructuring toUser - toUser: !!toUser ? { id: toUser.id, displayName: toUser.name, username: toUser.username } : null, - reason: !!reason ? reason.reason : null, - emoji: !!reason ? reason.emoji : null, - }; - } - - return acc; - }, {}); -}; - type GetUsersAvailabilityProps = { users: (GetAvailabilityUser & { currentBookings?: GetUserAvailabilityInitialData["currentBookings"]; @@ -724,32 +153,462 @@ type GetUsersAvailabilityProps = { initialData?: Omit; }; -const _getUsersAvailability = async ({ users, query, initialData }: GetUsersAvailabilityProps) => { - if (users.length >= 50) { - const userIds = users.map(({ id }) => id).join(", "); - log.warn( - `High-load warning: Attempting to fetch availability for ${users.length} users. User IDs: [${userIds}], EventTypeId: [${query.eventTypeId}]` +export interface IUserAvailabilityService { + eventTypeRepo: EventTypeRepository; + oooRepo: PrismaOOORepository; + bookingRepo: BookingRepository; + +} + +export class UserAvailabilityService { + constructor(public readonly dependencies: IUserAvailabilityService) {} + + async _getEventType(id: number) { + const eventType = await this.dependencies.eventTypeRepo.findByIdForUserAvailability({id}) + if (!eventType) { + return eventType; + } + return { + ...eventType, + metadata: EventTypeMetaDataSchema.parse(eventType.metadata), + }; + } + + getEventType = withReporting(this._getEventType.bind(this), "getEventType"); + + async _getUser(where: Prisma.UserWhereInput) { + return findUsersForAvailabilityCheck({ where }); + } + getUser = withReporting(this._getUser.bind(this), "getUser"); + + async _getCurrentSeats( + eventType: { + id?: number; + schedulingType?: SchedulingType | null; + hosts?: { + user: { + email: string; + }; + }[]; + }, + dateFrom: Dayjs, + dateTo: Dayjs + ) { + const { schedulingType, hosts, id } = eventType; + const hostEmails = hosts?.map((host) => host.user.email); + const isTeamEvent = + schedulingType === SchedulingType.MANAGED || + schedulingType === SchedulingType.ROUND_ROBIN || + schedulingType === SchedulingType.COLLECTIVE; + + const bookings = await this.dependencies.bookingRepo.findAcceptedBookingByEventTypeId({eventTypeId: id, dateFrom: dateFrom.format(), dateTo: dateTo.format()}) + + + + return bookings.map((booking) => { + const attendees = isTeamEvent + ? booking.attendees.filter((attendee) => !hostEmails?.includes(attendee.email)) + : booking.attendees; + + return { + uid: booking.uid, + startTime: booking.startTime, + _count: { + attendees: attendees.length, + }, + }; + }); + } + + getCurrentSeats = withReporting(this._getCurrentSeats.bind(this), "getCurrentSeats"); + + /** This should be called getUsersWorkingHoursAndBusySlots (...and remaining seats, and final timezone) */ + async _getUserAvailability(query: GetUserAvailabilityQuery, initialData?: GetUserAvailabilityInitialData) { + const { + username, + userId, + dateFrom, + dateTo, + eventTypeId, + afterEventBuffer, + beforeEventBuffer, + duration, + returnDateOverrides, + bypassBusyCalendarTimes = false, + shouldServeCache, + } = availabilitySchema.parse(query); + + log.debug( + `EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - Called with: ${safeStringify({ + query, + })}` + ); + + if (!dateFrom.isValid() || !dateTo.isValid()) { + throw new HttpError({ statusCode: 400, message: "Invalid time range given." }); + } + + const where: Prisma.UserWhereInput = {}; + if (username) where.username = username; + if (userId) where.id = userId; + + const user = initialData?.user || (await this.getUser(where)); + + if (!user) { + throw new HttpError({ statusCode: 404, message: "No user found in getUserAvailability" }); + } + + let eventType: EventType | null = initialData?.eventType || null; + if (!eventType && eventTypeId) eventType = await this.getEventType(eventTypeId); + + /* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab + current bookings with a seats event type and display them on the calendar, even if they are full */ + let currentSeats: CurrentSeats | null = initialData?.currentSeats || null; + if (!currentSeats && eventType?.seatsPerTimeSlot) { + currentSeats = await this.getCurrentSeats(eventType, dateFrom, dateTo); + } + + const userSchedule = user.schedules.filter( + (schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId + )[0]; + + const hostSchedule = eventType?.hosts?.find((host) => host.user.id === user.id)?.schedule; + + // TODO: It uses default timezone of user. Should we use timezone of team ? + const fallbackTimezoneIfScheduleIsMissing = eventType?.timeZone || user.timeZone; + + const fallbackSchedule = { + availability: [ + { + startTime: new Date("1970-01-01T09:00:00Z"), + endTime: new Date("1970-01-01T17:00:00Z"), + days: [1, 2, 3, 4, 5], // Monday to Friday + date: null, + }, + ], + id: 0, + + timeZone: fallbackTimezoneIfScheduleIsMissing, + }; + + const schedule = + (eventType?.schedule ? eventType.schedule : hostSchedule ? hostSchedule : userSchedule) ?? + fallbackSchedule; + const timeZone = schedule?.timeZone || fallbackTimezoneIfScheduleIsMissing; + + const bookingLimits = + eventType?.bookingLimits && + typeof eventType.bookingLimits === "object" && + Object.keys(eventType.bookingLimits).length > 0 + ? parseBookingLimit(eventType.bookingLimits) + : null; + + const durationLimits = + eventType?.durationLimits && + typeof eventType.durationLimits === "object" && + Object.keys(eventType.durationLimits).length > 0 + ? parseDurationLimit(eventType.durationLimits) + : null; + + let busyTimesFromLimits: EventBusyDetails[] = []; + + if (initialData?.busyTimesFromLimits && initialData?.eventTypeForLimits) { + busyTimesFromLimits = initialData.busyTimesFromLimits.get(user.id) || []; + } else if (eventType && (bookingLimits || durationLimits)) { + // Fall back to individual query if not available in initialData + busyTimesFromLimits = await getBusyTimesFromLimits( + bookingLimits, + durationLimits, + dateFrom.tz(timeZone), + dateTo.tz(timeZone), + duration, + eventType, + initialData?.busyTimesFromLimitsBookings ?? [], + timeZone, + initialData?.rescheduleUid ?? undefined + ); + } + + const teamForBookingLimits = + initialData?.teamForBookingLimits ?? + eventType?.team ?? + (eventType?.parent?.team?.includeManagedEventsInLimits ? eventType?.parent?.team : null); + + const teamBookingLimits = parseBookingLimit(teamForBookingLimits?.bookingLimits); + + let busyTimesFromTeamLimits: EventBusyDetails[] = []; + + if (initialData?.teamBookingLimits && teamForBookingLimits) { + busyTimesFromTeamLimits = initialData.teamBookingLimits.get(user.id) || []; + } else if (teamForBookingLimits && teamBookingLimits) { + // Fall back to individual query if not available in initialData + busyTimesFromTeamLimits = await getBusyTimesFromTeamLimits( + user, + teamBookingLimits, + dateFrom.tz(timeZone), + dateTo.tz(timeZone), + teamForBookingLimits.id, + teamForBookingLimits.includeManagedEventsInLimits, + timeZone, + initialData?.rescheduleUid ?? undefined + ); + } + + // TODO: only query what we need after applying limits (shrink date range) + const getBusyTimesStart = dateFrom.toISOString(); + const getBusyTimesEnd = dateTo.toISOString(); + + const selectedCalendars = eventType?.useEventLevelSelectedCalendars + ? EventTypeRepository.getSelectedCalendarsFromUser({ user, eventTypeId: eventType.id }) + : user.userLevelSelectedCalendars; + + let busyTimes = []; + try { + busyTimes = await getBusyTimes({ + credentials: user.credentials, + startTime: getBusyTimesStart, + endTime: getBusyTimesEnd, + eventTypeId, + userId: user.id, + userEmail: user.email, + username: `${user.username}`, + beforeEventBuffer, + afterEventBuffer, + selectedCalendars, + seatedEvent: !!eventType?.seatsPerTimeSlot, + rescheduleUid: initialData?.rescheduleUid || null, + duration, + currentBookings: initialData?.currentBookings, + bypassBusyCalendarTimes, + shouldServeCache, + }); + } catch (error) { + log.error(`Error fetching busy times for user ${username}:`, error); + return { + busy: [], + timeZone, + dateRanges: [], + oooExcludedDateRanges: [], + workingHours: [], + dateOverrides: [], + currentSeats: [], + datesOutOfOffice: undefined, + }; + } + + const detailedBusyTimes: EventBusyDetails[] = [ + ...busyTimes.map((a) => ({ + ...a, + start: dayjs(a.start).toISOString(), + end: dayjs(a.end).toISOString(), + title: a.title, + source: query.withSource ? a.source : undefined, + })), + ...busyTimesFromLimits, + ...busyTimesFromTeamLimits, + ]; + + const isDefaultSchedule = userSchedule && userSchedule.id === schedule?.id; + + log.debug( + `EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - usingSchedule: ${safeStringify({ + chosenSchedule: schedule, + eventTypeSchedule: eventType?.schedule, + userSchedule: userSchedule, + hostSchedule: hostSchedule, + })}` + ); + + if ( + !( + schedule?.availability || + (eventType?.availability.length ? eventType.availability : user.availability) + ) + ) { + throw new HttpError({ statusCode: 400, message: ErrorCode.AvailabilityNotFoundInSchedule }); + } + + const availability = ( + schedule?.availability || (eventType?.availability.length ? eventType.availability : user.availability) + ).map((a) => ({ + ...a, + userId: user.id, + })); + + const workingHours = getWorkingHours({ timeZone }, availability); + + const dateOverrides: TimeRange[] = []; + // NOTE: getSchedule is currently calling this function for every user in a team event + // but not using these values at all, wasting CPU. Adding this check here temporarily to avoid a larger refactor + // since other callers do using this data. + if (returnDateOverrides) { + const calculateDateOverridesSpan = Sentry.startInactiveSpan({ name: "calculateDateOverrides" }); + const availabilityWithDates = availability.filter((availability) => !!availability.date); + + for (let i = 0; i < availabilityWithDates.length; i++) { + const override = availabilityWithDates[i]; + const startTime = dayjs.utc(override.startTime); + const endTime = dayjs.utc(override.endTime); + const overrideStartDate = dayjs.utc(override.date).hour(startTime.hour()).minute(startTime.minute()); + const overrideEndDate = dayjs.utc(override.date).hour(endTime.hour()).minute(endTime.minute()); + if ( + overrideStartDate.isBetween(dateFrom, dateTo, null, "[]") || + overrideEndDate.isBetween(dateFrom, dateTo, null, "[]") + ) { + dateOverrides.push({ + start: overrideStartDate.toDate(), + end: overrideEndDate.toDate(), + }); + } + } + + calculateDateOverridesSpan.end(); + } + + const outOfOfficeDays = + initialData?.outOfOfficeDays ?? + (await this.dependencies.oooRepo.findUserOOODays({userId: user.id, dateFrom: dateFrom.toISOString(), dateTo: dateTo.toISOString()})); + + const datesOutOfOffice: IOutOfOfficeData = this.calculateOutOfOfficeRanges(outOfOfficeDays, availability); + + const { dateRanges, oooExcludedDateRanges } = buildDateRanges({ + dateFrom, + dateTo, + availability, + timeZone, + travelSchedules: isDefaultSchedule + ? user.travelSchedules.map((schedule) => { + return { + startDate: dayjs(schedule.startDate), + endDate: schedule.endDate ? dayjs(schedule.endDate) : undefined, + timeZone: schedule.timeZone, + }; + }) + : [], + outOfOffice: datesOutOfOffice, + }); + + const formattedBusyTimes = detailedBusyTimes.map((busy) => ({ + start: dayjs(busy.start), + end: dayjs(busy.end), + })); + + const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes); + const dateRangesInWhichUserIsAvailableWithoutOOO = subtract(oooExcludedDateRanges, formattedBusyTimes); + + const result = { + busy: detailedBusyTimes, + timeZone, + dateRanges: dateRangesInWhichUserIsAvailable, + oooExcludedDateRanges: dateRangesInWhichUserIsAvailableWithoutOOO, + workingHours, + dateOverrides, + currentSeats, + datesOutOfOffice, + }; + + log.debug( + `EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - Result: ${safeStringify(result)}` + ); + + return result; + } + + getUserAvailability = withReporting(this._getUserAvailability.bind(this), "getUserAvailability"); + + _getPeriodStartDatesBetween( + dateFrom: Dayjs, + dateTo: Dayjs, + period: IntervalLimitUnit, + timeZone?: string + ): Dayjs[] { + const dates = []; + let startDate = timeZone ? dayjs(dateFrom).tz(timeZone).startOf(period) : dayjs(dateFrom).startOf(period); + const endDate = timeZone ? dayjs(dateTo).tz(timeZone).endOf(period) : dayjs(dateTo).endOf(period); + + while (startDate.isBefore(endDate)) { + dates.push(startDate); + startDate = startDate.add(1, period); + } + return dates; + } + + getPeriodStartDatesBetween = withReporting( + this._getPeriodStartDatesBetween.bind(this), + "getPeriodStartDatesBetween" + ); + + calculateOutOfOfficeRanges( + outOfOfficeDays: GetUserAvailabilityInitialData["outOfOfficeDays"], + availability: GetUserAvailabilityParamsDTO["availability"] + ): IOutOfOfficeData { + if (!outOfOfficeDays || outOfOfficeDays.length === 0) { + return {}; + } + + return outOfOfficeDays.reduce((acc: IOutOfOfficeData, { start, end, toUser, user, reason }) => { + // here we should use startDate or today if start is before today + // consider timezone in start and end date range + const startDateRange = dayjs(start).utc().isBefore(dayjs().startOf("day").utc()) + ? dayjs().utc().startOf("day") + : dayjs(start).utc().startOf("day"); + + // get number of day in the week and see if it's on the availability + const flattenDays = Array.from(new Set(availability.flatMap((a) => ("days" in a ? a.days : [])))).sort( + (a, b) => a - b + ); + + const endDateRange = dayjs(end).utc().endOf("day"); + + for (let date = startDateRange; date.isBefore(endDateRange); date = date.add(1, "day")) { + const dayNumberOnWeek = date.day(); + + if (!flattenDays?.includes(dayNumberOnWeek)) { + continue; // Skip to the next iteration if day not found in flattenDays + } + + acc[date.format("YYYY-MM-DD")] = { + // @TODO: would be good having start and end availability time here, but for now should be good + // you can obtain that from user availability defined outside of here + fromUser: { id: user.id, displayName: user.name }, + // optional chaining destructuring toUser + toUser: !!toUser ? { id: toUser.id, displayName: toUser.name, username: toUser.username } : null, + reason: !!reason ? reason.reason : null, + emoji: !!reason ? reason.emoji : null, + }; + } + + return acc; + }, {}); + } + + async _getUsersAvailability({ users, query, initialData }: GetUsersAvailabilityProps) { + if (users.length >= 50) { + const userIds = users.map(({ id }) => id).join(", "); + log.warn( + `High-load warning: Attempting to fetch availability for ${users.length} users. User IDs: [${userIds}], EventTypeId: [${query.eventTypeId}]` + ); + } + return await Promise.all( + users.map((user) => + this._getUserAvailability( + { + ...query, + userId: user.id, + username: user.username || "", + }, + initialData + ? { + ...initialData, + user, + currentBookings: user.currentBookings, + outOfOfficeDays: user.outOfOfficeDays, + } + : undefined + ) + ) ); } - return await Promise.all( - users.map((user) => - _getUserAvailability( - { - ...query, - userId: user.id, - username: user.username || "", - }, - initialData - ? { - ...initialData, - user, - currentBookings: user.currentBookings, - outOfOfficeDays: user.outOfOfficeDays, - } - : undefined - ) - ) - ); -}; -export const getUsersAvailability = withReporting(_getUsersAvailability, "getUsersAvailability"); + getUsersAvailability = withReporting(this._getUsersAvailability.bind(this), "getUsersAvailability"); +} diff --git a/packages/lib/intervalLimits/server/getBusyTimesFromLimits.ts b/packages/lib/intervalLimits/server/getBusyTimesFromLimits.ts index c6d84a2bd2..eb687ec8c8 100644 --- a/packages/lib/intervalLimits/server/getBusyTimesFromLimits.ts +++ b/packages/lib/intervalLimits/server/getBusyTimesFromLimits.ts @@ -1,9 +1,9 @@ import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; import { getCheckBookingLimitsService } from "@calcom/lib/di/containers/booking-limits"; +import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability"; import { getStartEndDateforLimitCheck } from "@calcom/lib/getBusyTimes"; import type { EventType } from "@calcom/lib/getUserAvailability"; -import { getPeriodStartDatesBetween } from "@calcom/lib/getUserAvailability"; import { withReporting } from "@calcom/lib/sentryWrapper"; import { performance } from "@calcom/lib/server/perfObserver"; import { getTotalBookingDuration } from "@calcom/lib/server/queries/booking"; @@ -86,6 +86,7 @@ const _getBusyTimesFromBookingLimits = async (params: { includeManagedEvents?: boolean; timeZone?: string | null; }) => { + const userAvailabilityService = getUserAvailabilityService(); const { bookings, bookingLimits, @@ -105,7 +106,7 @@ const _getBusyTimesFromBookingLimits = async (params: { if (!limit) continue; const unit = intervalLimitKeyToUnit(key); - const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit); + const periodStartDates = userAvailabilityService.getPeriodStartDatesBetween(dateFrom, dateTo, unit); for (const periodStart of periodStartDates) { if (limitManager.isAlreadyBusy(periodStart, unit)) continue; @@ -162,12 +163,14 @@ const _getBusyTimesFromDurationLimits = async ( timeZone: string, rescheduleUid?: string ) => { + const userAvailabilityService = getUserAvailabilityService(); + for (const key of descendingLimitKeys) { const limit = durationLimits?.[key]; if (!limit) continue; const unit = intervalLimitKeyToUnit(key); - const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit); + const periodStartDates = userAvailabilityService.getPeriodStartDatesBetween(dateFrom, dateTo, unit); for (const periodStart of periodStartDates) { if (limitManager.isAlreadyBusy(periodStart, unit)) continue; diff --git a/packages/lib/server/i18n.ts b/packages/lib/server/i18n.ts index d635844001..34f073494a 100644 --- a/packages/lib/server/i18n.ts +++ b/packages/lib/server/i18n.ts @@ -1,12 +1,13 @@ import { createInstance } from "i18next"; -/* eslint-disable @typescript-eslint/no-var-requires */ -const { i18n } = require("@calcom/config/next-i18next.config"); import { WEBAPP_URL } from "@calcom/lib/constants"; import { fetchWithTimeout } from "../fetchWithTimeout"; import logger from "../logger"; +/* eslint-disable @typescript-eslint/no-var-requires */ +const { i18n } = require("@calcom/config/next-i18next.config"); + const translationCache = new Map>(); const i18nInstanceCache = new Map(); const SUPPORTED_NAMESPACES = ["common"]; @@ -28,22 +29,28 @@ export async function loadTranslations(_locale: string, _ns: string) { } const url = `${WEBAPP_URL}/static/locales/${locale}/${ns}.json`; - const response = await fetchWithTimeout( - url, - { - cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store", - }, - 3000 - ); + try { + const response = await fetchWithTimeout( + url, + { + cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store", + }, + 3000 + ); + + if (!response.ok) { + logger.error(`Failed to fetch translations: ${response.status}`); + return {}; + } + + const translations = await response.json(); + translationCache.set(cacheKey, translations); + return translations; + } catch (err) { + console.error("loadTranslations Error:", err); - if (!response.ok) { - logger.error(`Failed to fetch translations: ${response.status}`); return {}; } - - const translations = await response.json(); - translationCache.set(cacheKey, translations); - return translations; } /** diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index f4e7a2495b..2fb9c906af 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -869,4 +869,26 @@ export class BookingRepository { }, }); } + + async findAcceptedBookingByEventTypeId({eventTypeId, dateFrom, dateTo}: {eventTypeId?: number, dateFrom: string, dateTo: string}) { + return this.prismaClient.booking.findMany({ + where: { + eventTypeId, + startTime: { + gte: dateFrom, + lte: dateTo, + }, + status: BookingStatus.ACCEPTED, + }, + select: { + uid: true, + startTime: true, + attendees: { + select: { + email: true, + }, + }, + }, + }); + } } diff --git a/packages/lib/server/repository/eventTypeRepository.ts b/packages/lib/server/repository/eventTypeRepository.ts index 325927fc86..2c58050292 100644 --- a/packages/lib/server/repository/eventTypeRepository.ts +++ b/packages/lib/server/repository/eventTypeRepository.ts @@ -1299,4 +1299,93 @@ export class EventTypeRepository { }) { return user.allSelectedCalendars.filter((calendar) => calendar.eventTypeId === eventTypeId); } + + async findByIdForUserAvailability({ id }: { id: number }) { + const eventType = await this.prismaClient.eventType.findUnique({ + where: { id }, + select: { + id: true, + seatsPerTimeSlot: true, + bookingLimits: true, + useEventLevelSelectedCalendars: true, + parent: { + select: { + team: { + select: { + id: true, + bookingLimits: true, + includeManagedEventsInLimits: true, + }, + }, + }, + }, + team: { + select: { + id: true, + bookingLimits: true, + includeManagedEventsInLimits: true, + }, + }, + hosts: { + select: { + user: { + select: { + email: true, + id: true, + }, + }, + schedule: { + select: { + availability: { + select: { + date: true, + startTime: true, + endTime: true, + days: true, + }, + }, + timeZone: true, + id: true, + }, + }, + }, + }, + durationLimits: true, + assignAllTeamMembers: true, + schedulingType: true, + timeZone: true, + length: true, + metadata: true, + schedule: { + select: { + id: true, + availability: { + select: { + days: true, + date: true, + startTime: true, + endTime: true, + }, + }, + timeZone: true, + }, + }, + availability: { + select: { + startTime: true, + endTime: true, + days: true, + date: true, + }, + }, + }, + }); + if (!eventType) { + return eventType; + } + return { + ...eventType, + metadata: EventTypeMetaDataSchema.parse(eventType.metadata), + }; + } } diff --git a/packages/lib/server/repository/ooo.ts b/packages/lib/server/repository/ooo.ts index a3b79626d3..20b8a50e8b 100644 --- a/packages/lib/server/repository/ooo.ts +++ b/packages/lib/server/repository/ooo.ts @@ -79,4 +79,71 @@ export class PrismaOOORepository { }, }); } + + async findUserOOODays({userId, dateTo, dateFrom}: {userId: number, dateTo: string, dateFrom: string}) { + return this.prismaClient.outOfOfficeEntry.findMany({ + where: { + userId, + OR: [ + // outside of range + // (start <= 'dateTo' AND end >= 'dateFrom') + { + start: { + lte: dateTo, + }, + end: { + gte: dateFrom, + }, + }, + // start is between dateFrom and dateTo but end is outside of range + // (start <= 'dateTo' AND end >= 'dateTo') + { + start: { + lte: dateTo, + }, + + end: { + gte: dateTo, + }, + }, + // end is between dateFrom and dateTo but start is outside of range + // (start <= 'dateFrom' OR end <= 'dateTo') + { + start: { + lte: dateFrom, + }, + + end: { + lte: dateTo, + }, + }, + ], + }, + select: { + id: true, + start: true, + end: true, + user: { + select: { + id: true, + name: true, + }, + }, + toUser: { + select: { + id: true, + username: true, + name: true, + }, + }, + reason: { + select: { + id: true, + emoji: true, + reason: true, + }, + }, + }, + }); + } } diff --git a/packages/platform/libraries/schedules.ts b/packages/platform/libraries/schedules.ts index 6ab5ba3971..c649bb0ed5 100644 --- a/packages/platform/libraries/schedules.ts +++ b/packages/platform/libraries/schedules.ts @@ -4,3 +4,4 @@ export { } from "@calcom/lib/server/repository/schedule"; export { updateSchedule, type UpdateScheduleResponse } from "@calcom/lib/schedules/updateSchedule"; +export {UserAvailabilityService} from "@calcom/lib/getUserAvailability" \ No newline at end of file diff --git a/packages/trpc/server/routers/viewer/availability/user.handler.ts b/packages/trpc/server/routers/viewer/availability/user.handler.ts index d52b4ebc6e..16358c0d9d 100644 --- a/packages/trpc/server/routers/viewer/availability/user.handler.ts +++ b/packages/trpc/server/routers/viewer/availability/user.handler.ts @@ -1,4 +1,4 @@ -import { getUserAvailability } from "@calcom/lib/getUserAvailability"; +import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability"; import type { TrpcSessionUser } from "../../../types"; import type { TUserInputSchema } from "./user.schema"; @@ -11,7 +11,8 @@ type UserOptions = { }; export const userHandler = async ({ input }: UserOptions) => { - return getUserAvailability( + const userAvailabilityService = getUserAvailabilityService() + return userAvailabilityService.getUserAvailability( { returnDateOverrides: true, bypassBusyCalendarTimes: false, ...input }, undefined ); diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index e3f6ae93c1..a1a997d6d3 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -20,10 +20,10 @@ import type { CurrentSeats, EventType, GetAvailabilityUser, + UserAvailabilityService, IFromUser, IToUser, } from "@calcom/lib/getUserAvailability"; -import { getPeriodStartDatesBetween, getUsersAvailability } from "@calcom/lib/getUserAvailability"; import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimits/intervalLimit"; import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema"; import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits"; @@ -101,6 +101,7 @@ export interface IAvailableSlotsService { routingFormResponseRepo: RoutingFormResponseRepository; cacheService: CacheService; checkBookingLimitsService: CheckBookingLimitsService; + userAvailabilityService: UserAvailabilityService } export class AvailableSlotsService { @@ -357,7 +358,7 @@ export class AvailableSlotsService { if (!limit) continue; const unit = intervalLimitKeyToUnit(key); - const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone); + const periodStartDates = this.dependencies.userAvailabilityService.getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone); for (const periodStart of periodStartDates) { if (globalLimitManager.isAlreadyBusy(periodStart, unit, timeZone)) continue; @@ -392,7 +393,7 @@ export class AvailableSlotsService { if (!limit) continue; const unit = intervalLimitKeyToUnit(key); - const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone); + const periodStartDates = this.dependencies.userAvailabilityService.getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone); for (const periodStart of periodStartDates) { if (limitManager.isAlreadyBusy(periodStart, unit, timeZone)) continue; @@ -443,7 +444,7 @@ export class AvailableSlotsService { if (!limit) continue; const unit = intervalLimitKeyToUnit(key); - const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone); + const periodStartDates = this.dependencies.userAvailabilityService.getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone); for (const periodStart of periodStartDates) { if (limitManager.isAlreadyBusy(periodStart, unit, timeZone)) continue; @@ -543,7 +544,7 @@ export class AvailableSlotsService { if (!limit) continue; const unit = intervalLimitKeyToUnit(key); - const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone); + const periodStartDates = this.dependencies.userAvailabilityService.getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone); for (const periodStart of periodStartDates) { if (globalLimitManager.isAlreadyBusy(periodStart, unit, timeZone)) continue; @@ -591,7 +592,7 @@ export class AvailableSlotsService { if (!limit) continue; const unit = intervalLimitKeyToUnit(key); - const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone); + const periodStartDates = this.dependencies.userAvailabilityService.getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone); for (const periodStart of periodStartDates) { if (limitManager.isAlreadyBusy(periodStart, unit, timeZone)) continue; @@ -818,7 +819,7 @@ export class AvailableSlotsService { const users = enrichUsersWithData(); // TODO: DI getUsersAvailability - const premappedUsersAvailability = await getUsersAvailability({ + const premappedUsersAvailability = await this.dependencies.userAvailabilityService.getUsersAvailability({ users, query: { dateFrom: startTime.format(), diff --git a/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts b/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts index c236c15245..db6994b81f 100644 --- a/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/getMemberAvailability.handler.ts @@ -1,5 +1,5 @@ import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/lib/delegationCredential/server"; -import { getUserAvailability } from "@calcom/lib/getUserAvailability"; +import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability"; import { isTeamMember } from "@calcom/lib/server/queries/teams"; import { MembershipRepository } from "@calcom/lib/server/repository/membership"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; @@ -16,6 +16,7 @@ type GetMemberAvailabilityOptions = { }; export const getMemberAvailabilityHandler = async ({ ctx, input }: GetMemberAvailabilityOptions) => { + const userAvailabilityService = getUserAvailabilityService() const team = await isTeamMember(ctx.user?.id, input.teamId); if (!team) throw new TRPCError({ code: "UNAUTHORIZED" }); @@ -32,7 +33,7 @@ export const getMemberAvailabilityHandler = async ({ ctx, input }: GetMemberAvai }); // get availability for this member - return await getUserAvailability( + return await userAvailabilityService.getUserAvailability( { username: username, dateFrom: input.dateFrom, diff --git a/yarn.lock b/yarn.lock index 70536df6e3..62bd2d9cc7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2500,7 +2500,7 @@ __metadata: "@axiomhq/winston": ^1.2.0 "@calcom/platform-constants": "*" "@calcom/platform-enums": "*" - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.276" + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.283" "@calcom/platform-types": "*" "@calcom/platform-utils": "*" "@calcom/prisma": "*" @@ -3556,13 +3556,13 @@ __metadata: languageName: unknown linkType: soft -"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.276": - version: 0.0.276 - resolution: "@calcom/platform-libraries@npm:0.0.276" +"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.283": + version: 0.0.283 + resolution: "@calcom/platform-libraries@npm:0.0.283" dependencies: "@calcom/features": "*" "@calcom/lib": "*" - checksum: 4effc186c0301ec35496ebffbe32280f26261667b60057d18207b30636b038cdcc3783b95390eecc8460f3203d0cfe5d732e3b3f89031ff064b1a556321779a8 + checksum: 7d741bd13ac050f8552855658b2348cba0999529c12ca6741e5f802a7bc9ce4e29c9ac69a17306c42beb392f17acb7b5f8a0554b8d2ea9b5fc6d963950d0fca8 languageName: node linkType: hard