diff --git a/apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx b/apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx index 7b7f2b52d2fa4c..bad7380818dbf5 100644 --- a/apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx +++ b/apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx @@ -380,26 +380,26 @@ const calendarComponents = {
{isConnectedCalendarSettingsApplicable ? showConnectedCalendarSettings && ( -
- }> - {!isPlatform && ( - { - const chosenScopeIsEventLevel = scope === SelectedCalendarSettingsScope.EventType; - formMethods.setValue("useEventLevelSelectedCalendars", chosenScopeIsEventLevel, { - shouldDirty: true, - }); - }} - /> - )} - -
- ) +
+ }> + {!isPlatform && ( + { + const chosenScopeIsEventLevel = scope === SelectedCalendarSettingsScope.EventType; + formMethods.setValue("useEventLevelSelectedCalendars", chosenScopeIsEventLevel, { + shouldDirty: true, + }); + }} + /> + )} + +
+ ) : null}
@@ -429,7 +429,7 @@ export const EventAdvancedTab = ({ const [lightModeError, setLightModeError] = useState(false); const [multiplePrivateLinksVisible, setMultiplePrivateLinksVisible] = useState( !!formMethods.getValues("multiplePrivateLinks") && - formMethods.getValues("multiplePrivateLinks")?.length !== 0 + formMethods.getValues("multiplePrivateLinks")?.length !== 0 ); const watchedInterfaceLanguage = formMethods.watch("interfaceLanguage"); const [interfaceLanguageVisible, setInterfaceLanguageVisible] = useState( @@ -546,6 +546,7 @@ export const EventAdvancedTab = ({ ); const showOptimizedSlotsLocked = shouldLockDisableProps("showOptimizedSlots"); + const minimizeGapsLocked = shouldLockDisableProps("minimizeGaps"); const closeEventNameTip = () => setShowEventNameTip(false); @@ -1513,6 +1514,23 @@ export const EventAdvancedTab = ({ ); }} /> + ( + { + onChange(active ?? false); + }} + switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6" + /> + )} + /> {isRoundRobinEventType && ( { + const teamCreditBalance = await CreditsRepository.findCreditBalance({teamId}, tx); + + if(!teamCreditBalance || teamCreditBalance.additionalCredits <= 0) { + return ; + } + + let userCreditBalance = await CreditsRepository.findCreditBalance({userId}, tx); + + if(!userCreditBalance) { + userCreditBalance = await CreditsRepository.createCreditBalance( + {userId}, + tx + ); + } + + const creditsToTransfer = teamCreditBalance.additionalCredits; + + logger.info("Moving credits from team to owner" , { + teamId, + userId, + creditsToTransfer, + }) + + await CreditsRepository.updateCreditBalance ( + { + teamId, + data : {additionalCredits : 0}, + }, + tx + ); + + await CreditsRepository.updateCreditBalance( + { + userId , + data : { + additionalCredits : { + increment : creditsToTransfer, + }, + }, + }, + tx + ); + return { creditsTransferred : creditsToTransfer }; + }); + } + async moveCreditsFromTeamToOrg({ teamId, orgId }: { teamId: number; orgId: number }) { return await prisma.$transaction(async (tx) => { // Get team's credit balance diff --git a/packages/features/ee/billing/service/teams/TeamBillingService.ts b/packages/features/ee/billing/service/teams/TeamBillingService.ts index bb6979d459cef6..8a3254403048d6 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingService.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingService.ts @@ -22,6 +22,8 @@ import { type TeamBillingInput, TeamBillingPublishResponseStatus, } from "./ITeamBillingService"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { CreditService } from "../../credit-service" const log = logger.getSubLogger({ prefix: ["TeamBilling"] }); @@ -133,6 +135,25 @@ export class TeamBillingService implements ITeamBillingService { } async downgrade() { try { + const creditService = new CreditService(); + + const owner = await prisma.membership.findFirst({ + where : { + teamId : this.team.id, + role : MembershipRole.OWNER + }, + }); + + if(!owner) { + throw new Error(`No owner found for team ${this.team.id}`); + } + + await creditService.moveCreditsFromTeamToUser({ + teamId : this.team.id, + userId : owner.userId, + }); + + const { mergeMetadata } = getMetadataHelpers(teamPaymentMetadataSchema, this.team.metadata); const metadata = mergeMetadata({ paymentId: undefined, diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index c3174fbe460712..20edd5657de06b 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -171,6 +171,7 @@ export type FormValues = { bookingLimits?: IntervalLimit; onlyShowFirstAvailableSlot: boolean; showOptimizedSlots: boolean; + minimizeGaps?: boolean | null; children: ChildrenEventType[]; hosts: Host[]; hostGroups: { diff --git a/packages/features/eventtypes/repositories/eventTypeRepository.ts b/packages/features/eventtypes/repositories/eventTypeRepository.ts index a620abace16c89..df582c2274a8ba 100644 --- a/packages/features/eventtypes/repositories/eventTypeRepository.ts +++ b/packages/features/eventtypes/repositories/eventTypeRepository.ts @@ -1340,6 +1340,7 @@ export class EventTypeRepository implements IEventTypesRepository { allowReschedulingPastBookings: true, hideOrganizerEmail: true, showOptimizedSlots: true, + minimizeGaps: true , periodCountCalendarDays: true, rescheduleWithSameRoundRobinHost: true, periodDays: true, diff --git a/packages/features/schedules/lib/slots.ts b/packages/features/schedules/lib/slots.ts index a8733fecf95932..9efa91fcbb3e2f 100644 --- a/packages/features/schedules/lib/slots.ts +++ b/packages/features/schedules/lib/slots.ts @@ -18,6 +18,8 @@ export type GetSlots = { offsetStart?: number; datesOutOfOffice?: IOutOfOfficeData; showOptimizedSlots?: boolean | null; + minimizeGaps? : boolean | null; + existingBookings? : {startTime : Dayjs; endTime : Dayjs}[]; datesOutOfOfficeTimeZone?: string; }; export type TimeFrame = { userIds?: number[]; startTime: number; endTime: number }; @@ -77,6 +79,8 @@ function buildSlotsWithDateRanges({ offsetStart, datesOutOfOffice, showOptimizedSlots, + minimizeGaps, + existingBookings, datesOutOfOfficeTimeZone, }: { dateRanges: DateRange[]; @@ -87,6 +91,8 @@ function buildSlotsWithDateRanges({ offsetStart?: number; datesOutOfOffice?: IOutOfOfficeData; showOptimizedSlots?: boolean | null; + minimizeGaps? : boolean | null; + existingBookings? : {startTime : Dayjs; endTime : Dayjs}[]; datesOutOfOfficeTimeZone?: string; }) { // keep the old safeguards in; may be needed. @@ -226,6 +232,24 @@ function buildSlotsWithDateRanges({ } }); + if(minimizeGaps && existingBookings && existingBookings.length > 0) { + const validStartTimes = new Set(); + + for(const booking of existingBookings) { + validStartTimes.add(booking.endTime.toISOString()); + + validStartTimes.add( + booking.startTime.subtract(eventLength, "minutes").toISOString() + ); + } + + const filtered = Array.from(slots.values().filter((slot) => + validStartTimes.has(slot.time.toISOString()) + )) + + return filtered.length > 0 ? filtered : Array.from(slots.values()); + } + return Array.from(slots.values()); } diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 86a550d252913b..5693d65d129717 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -294,6 +294,8 @@ model EventType { createdAt DateTime? @default(now()) updatedAt DateTime? @updatedAt + minimizeGaps Boolean @default(false) + // @@partial_index([instantMeetingScheduleId]) @@unique([userId, slug]) diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 433672093bbef3..816b17eb761dca 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -130,7 +130,7 @@ function withSlotsCache( } export class AvailableSlotsService { - constructor(public readonly dependencies: IAvailableSlotsService) {} + constructor(public readonly dependencies: IAvailableSlotsService) { } private async _getReservedSlotsAndCleanupExpired({ bookerClientUid, @@ -916,15 +916,15 @@ export class AvailableSlotsService { const bookingLimits = eventType?.bookingLimits && - typeof eventType?.bookingLimits === "object" && - Object.keys(eventType?.bookingLimits).length > 0 + 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 + typeof eventType?.durationLimits === "object" && + Object.keys(eventType?.durationLimits).length > 0 ? parseDurationLimit(eventType?.durationLimits) : null; @@ -1051,6 +1051,7 @@ export class AvailableSlotsService { allUsersAvailability, usersWithCredentials, currentSeats, + currentBookingsAllUsers, }; } @@ -1141,9 +1142,9 @@ export class AvailableSlotsService { } = input; const orgDetails = input?.orgSlug ? { - currentOrgDomain: input.orgSlug, - isValidOrgDomain: !!input.orgSlug && !RESERVED_SUBDOMAINS.includes(input.orgSlug), - } + currentOrgDomain: input.orgSlug, + isValidOrgDomain: !!input.orgSlug && !RESERVED_SUBDOMAINS.includes(input.orgSlug), + } : orgDomainConfig(ctx?.req); if (process.env.INTEGRATION_TEST_MODE === "true") { @@ -1252,7 +1253,7 @@ export class AvailableSlotsService { const hasFallbackRRHosts = eligibleFallbackRRHosts.length > 0 && eligibleFallbackRRHosts.length > eligibleQualifiedRRHosts.length; - let { allUsersAvailability, usersWithCredentials, currentSeats } = + let { allUsersAvailability, usersWithCredentials, currentSeats, currentBookingsAllUsers } = await this.calculateHostsAndAvailabilities({ input, eventType, @@ -1354,6 +1355,11 @@ export class AvailableSlotsService { datesOutOfOffice: !isTeamEvent ? allUsersAvailability[0]?.datesOutOfOffice : undefined, showOptimizedSlots: eventType.showOptimizedSlots, datesOutOfOfficeTimeZone: !isTeamEvent ? allUsersAvailability[0]?.timeZone : undefined, + minimizeGaps: eventType.minimizeGaps, + existingBookings: currentBookingsAllUsers.map((b) => ({ + startTime: dayjs(b.startTime), + endTime: dayjs(b.endTime), + })), }); let availableTimeSlots: typeof timeSlots = []; @@ -1388,10 +1394,10 @@ export class AvailableSlotsService { const travelSchedules = isDefaultSchedule && !eventType.useBookerTimezone ? restrictionSchedule.user.travelSchedules.map((schedule) => ({ - startDate: dayjs(schedule.startDate), - endDate: schedule.endDate ? dayjs(schedule.endDate) : undefined, - timeZone: schedule.timeZone, - })) + startDate: dayjs(schedule.startDate), + endDate: schedule.endDate ? dayjs(schedule.endDate) : undefined, + timeZone: schedule.timeZone, + })) : []; const { dateRanges: restrictionRanges } = buildDateRanges({ @@ -1486,9 +1492,9 @@ export class AvailableSlotsService { ( item: | { - time: dayjs.Dayjs; - userIds?: number[] | undefined; - } + time: dayjs.Dayjs; + userIds?: number[] | undefined; + } | undefined ): item is { time: dayjs.Dayjs; @@ -1664,23 +1670,23 @@ export class AvailableSlotsService { const troubleshooterData = enableTroubleshooter ? { - troubleshooter: { - routedTeamMemberIds: routedTeamMemberIds, - // One that Salesforce asked for - askedContactOwner: contactOwnerEmailFromInput, - // One that we used as per Routing skipContactOwner flag - consideredContactOwner: contactOwnerEmail, - // All hosts that have been checked for availability. If no routedTeamMemberIds are provided, this will be same as hosts. - routedHosts: usersWithCredentials.map((user) => { - return { - userId: user.id, - }; - }), - hostsAfterSegmentMatching: allHosts.map((host) => ({ - userId: host.user.id, - })), - }, - } + troubleshooter: { + routedTeamMemberIds: routedTeamMemberIds, + // One that Salesforce asked for + askedContactOwner: contactOwnerEmailFromInput, + // One that we used as per Routing skipContactOwner flag + consideredContactOwner: contactOwnerEmail, + // All hosts that have been checked for availability. If no routedTeamMemberIds are provided, this will be same as hosts. + routedHosts: usersWithCredentials.map((user) => { + return { + userId: user.id, + }; + }), + hostsAfterSegmentMatching: allHosts.map((host) => ({ + userId: host.user.id, + })), + }, + } : null; return {