Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -380,26 +380,26 @@ const calendarComponents = {
<div>
{isConnectedCalendarSettingsApplicable
? showConnectedCalendarSettings && (
<div className="mt-4">
<Suspense fallback={<SelectedCalendarsSettingsWebWrapperSkeleton />}>
{!isPlatform && (
<SelectedCalendarsSettingsWebWrapper
eventTypeId={eventType.id}
disabledScope={SelectedCalendarSettingsScope.User}
disableConnectionModification={true}
scope={selectedCalendarSettingsScope}
destinationCalendarId={destinationCalendar?.externalId}
setScope={(scope) => {
const chosenScopeIsEventLevel = scope === SelectedCalendarSettingsScope.EventType;
formMethods.setValue("useEventLevelSelectedCalendars", chosenScopeIsEventLevel, {
shouldDirty: true,
});
}}
/>
)}
</Suspense>
</div>
)
<div className="mt-4">
<Suspense fallback={<SelectedCalendarsSettingsWebWrapperSkeleton />}>
{!isPlatform && (
<SelectedCalendarsSettingsWebWrapper
eventTypeId={eventType.id}
disabledScope={SelectedCalendarSettingsScope.User}
disableConnectionModification={true}
scope={selectedCalendarSettingsScope}
destinationCalendarId={destinationCalendar?.externalId}
setScope={(scope) => {
const chosenScopeIsEventLevel = scope === SelectedCalendarSettingsScope.EventType;
formMethods.setValue("useEventLevelSelectedCalendars", chosenScopeIsEventLevel, {
shouldDirty: true,
});
}}
/>
)}
</Suspense>
</div>
)
: null}
</div>
</div>
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -546,6 +546,7 @@ export const EventAdvancedTab = ({
);

const showOptimizedSlotsLocked = shouldLockDisableProps("showOptimizedSlots");
const minimizeGapsLocked = shouldLockDisableProps("minimizeGaps");

const closeEventNameTip = () => setShowEventNameTip(false);

Expand Down Expand Up @@ -1513,6 +1514,23 @@ export const EventAdvancedTab = ({
);
}}
/>
<Controller
name="minimizeGaps"
render={({ field: { onChange, value } }) => (
<SettingsToggle
toggleSwitchAtTheEnd={true}
labelClassName="text-sm"
title={t("minimize_gaps")}
description={t("minimize_gaps_description")}
checked={!!value}
{...minimizeGapsLocked}
onCheckedChange={(active) => {
onChange(active ?? false);
}}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
/>
)}
/>
{isRoundRobinEventType && (
<Controller
name="rescheduleWithSameRoundRobinHost"
Expand Down
48 changes: 48 additions & 0 deletions packages/features/ee/billing/credit-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,54 @@ export class CreditService {
};
}

async moveCreditsFromTeamToUser({teamId , userId} : {teamId : number; userId : number}) {
return await prisma.$transaction(async (tx) => {
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 };
Comment on lines +795 to +839
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Prevent double-transferring the same balance.

This captures teamCreditBalance.additionalCredits before the source balance is cleared, so two concurrent downgrade calls can both read the same amount and both increment the owner's balance. Please make the debit atomic on the source row first, then apply the increment to the user balance.

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

In `@packages/features/ee/billing/credit-service.ts` around lines 795 - 839, The
current moveCreditsFromTeamToUser reads teamCreditBalance.additionalCredits then
clears it, which allows races; instead perform an atomic debit on the team row
first and capture the previous amount, then increment the user. Modify
moveCreditsFromTeamToUser to replace the read-then-later update with a single
atomic operation (using CreditsRepository via tx or a tx.$queryRaw SQL UPDATE
... RETURNING previous additionalCredits / use UPDATE ... WHERE teamId = ? AND
additionalCredits > 0 RETURNING additionalCredits) to set additionalCredits = 0
and return the amount actually debited, then only if that returned amount > 0
perform the CreditsRepository.updateCreditBalance call to increment the user by
that returned amount; keep everything inside the same prisma.$transaction.

});
}

async moveCreditsFromTeamToOrg({ teamId, orgId }: { teamId: number; orgId: number }) {
return await prisma.$transaction(async (tx) => {
// Get team's credit balance
Expand Down
21 changes: 21 additions & 0 deletions packages/features/ee/billing/service/teams/TeamBillingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] });

Expand Down Expand Up @@ -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,
});
Comment on lines +138 to +154
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keep the downgrade writes in one transaction.

moveCreditsFromTeamToUser() commits before the billing metadata is cleared. If the prisma.team.update at Line 163 fails, the owner keeps the transferred credits while the team still looks subscribed, and this method only logs the failure. Please run the owner lookup, credit transfer, and metadata update in the same DB transaction or add compensation.

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

In `@packages/features/ee/billing/service/teams/TeamBillingService.ts` around
lines 138 - 154, The owner lookup (prisma.membership.findFirst), the credit
transfer (CreditService.moveCreditsFromTeamToUser) and the team billing metadata
update (prisma.team.update) must be executed in a single DB transaction to avoid
partial commits; refactor this code to use a Prisma transaction
(prisma.$transaction) or accept a transactional client: fetch the owner inside
the transaction, call moveCreditsFromTeamToUser with the same transaction
context (or convert that method to accept a Prisma transaction/client), then
perform prisma.team.update within the same transaction so either all succeed or
all roll back; if changing moveCreditsFromTeamToUser is infeasible, implement a
compensating rollback step invoked on prisma.team.update failure to revert the
credit move.



const { mergeMetadata } = getMetadataHelpers(teamPaymentMetadataSchema, this.team.metadata);
const metadata = mergeMetadata({
paymentId: undefined,
Expand Down
1 change: 1 addition & 0 deletions packages/features/eventtypes/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export type FormValues = {
bookingLimits?: IntervalLimit;
onlyShowFirstAvailableSlot: boolean;
showOptimizedSlots: boolean;
minimizeGaps?: boolean | null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add minimizeGaps to the shared update contract.

This file now carries the flag in FormValues, but EventTypeUpdateInput below still omits it. Since that type is documented as the shared update shape, the write path is still incomplete and can drop the setting on save.

Suggested fix
   onlyShowFirstAvailableSlot?: boolean;
   showOptimizedSlots?: boolean | null;
+  minimizeGaps?: boolean | null;
   disableCancelling?: boolean | null;
   disableRescheduling?: boolean | null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/features/eventtypes/lib/types.ts` at line 174, The
EventTypeUpdateInput type is missing the minimizeGaps flag present in
FormValues, so update the shared update contract by adding minimizeGaps?:
boolean | null to the EventTypeUpdateInput definition; locate
EventTypeUpdateInput in types.ts and mirror the FormValues property signature to
ensure the write path preserves the setting when saving.

children: ChildrenEventType[];
hosts: Host[];
hostGroups: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1340,6 +1340,7 @@ export class EventTypeRepository implements IEventTypesRepository {
allowReschedulingPastBookings: true,
hideOrganizerEmail: true,
showOptimizedSlots: true,
minimizeGaps: true ,
periodCountCalendarDays: true,
rescheduleWithSameRoundRobinHost: true,
periodDays: true,
Expand Down
24 changes: 24 additions & 0 deletions packages/features/schedules/lib/slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -77,6 +79,8 @@ function buildSlotsWithDateRanges({
offsetStart,
datesOutOfOffice,
showOptimizedSlots,
minimizeGaps,
existingBookings,
datesOutOfOfficeTimeZone,
}: {
dateRanges: DateRange[];
Expand All @@ -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.
Expand Down Expand Up @@ -226,6 +232,24 @@ function buildSlotsWithDateRanges({
}
});

if(minimizeGaps && existingBookings && existingBookings.length > 0) {
const validStartTimes = new Set<string>();

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());
}
Comment on lines +235 to +251
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '235,251p' packages/features/schedules/lib/slots.ts
rg -n '\.values\(\)\.filter\(' packages/features/schedules/lib/slots.ts

Repository: calcom/cal.com

Length of output: 664


🏁 Script executed:

sed -n '256,285p' packages/features/schedules/lib/slots.ts

Repository: calcom/cal.com

Length of output: 657


🏁 Script executed:

rg -A 15 'const buildSlotsWithDateRanges = \(' packages/features/schedules/lib/slots.ts | head -30

Repository: calcom/cal.com

Length of output: 40


🏁 Script executed:

rg -n "buildSlotsWithDateRanges" packages/features/schedules/lib/slots.ts | head -20

Repository: calcom/cal.com

Length of output: 136


🏁 Script executed:

sed -n '73,120p' packages/features/schedules/lib/slots.ts

Repository: calcom/cal.com

Length of output: 1431


This filter branch won't work as written, and the feature is disconnected from the public API.

Two critical issues:

  1. Invalid Iterator call: slots.values() returns a map iterator, which does not have a .filter() method. This must be wrapped in Array.from() before filtering.

  2. Missing wrapper parameters: The exported getSlots function (lines 256-285) does not accept minimizeGaps or existingBookings parameters and does not forward them to buildSlotsWithDateRanges. Even if the first bug is fixed, the feature remains completely non-functional because callers cannot enable it.

The fallback filtered.length > 0 ? filtered : Array.from(slots.values()) also silently disables minimizeGaps in the absence of valid slots, defeating the intended behavior.

Suggested fix for issue 1
   if (minimizeGaps && existingBookings && existingBookings.length > 0) {
     const validStartTimes = new Set<string>();

     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());
+    const filtered = Array.from(slots.values()).filter((slot) =>
+      validStartTimes.has(slot.time.toISOString())
+    );
+
+    return filtered;
   }

Fix issue 2 by adding minimizeGaps and existingBookings to the getSlots function signature (lines 256-268) and forwarding them to buildSlotsWithDateRanges (lines 275-283).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if(minimizeGaps && existingBookings && existingBookings.length > 0) {
const validStartTimes = new Set<string>();
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());
}
if (minimizeGaps && existingBookings && existingBookings.length > 0) {
const validStartTimes = new Set<string>();
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;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/features/schedules/lib/slots.ts` around lines 235 - 251, The filter
branch should convert the Map iterator to an array before filtering and preserve
the minimizeGaps intent, and the public API must accept/forward the new params:
wrap slots.values() with Array.from(...) before calling .filter() (use
slot.time.toISOString() vs validStartTimes), and if minimizeGaps is true return
the filtered array even when empty (don’t fall back to full slots). Also add
minimizeGaps and existingBookings to the exported getSlots signature and forward
them into buildSlotsWithDateRanges so callers can enable the feature; update
references to eventLength, slots, buildSlotsWithDateRanges, getSlots,
minimizeGaps, and existingBookings accordingly.


return Array.from(slots.values());
}

Expand Down
2 changes: 2 additions & 0 deletions packages/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@ model EventType {
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt

minimizeGaps Boolean @default(false)

// @@partial_index([instantMeetingScheduleId])

@@unique([userId, slug])
Expand Down
72 changes: 39 additions & 33 deletions packages/trpc/server/routers/viewer/slots/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ function withSlotsCache(
}

export class AvailableSlotsService {
constructor(public readonly dependencies: IAvailableSlotsService) {}
constructor(public readonly dependencies: IAvailableSlotsService) { }

private async _getReservedSlotsAndCleanupExpired({
bookerClientUid,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -1051,6 +1051,7 @@ export class AvailableSlotsService {
allUsersAvailability,
usersWithCredentials,
currentSeats,
currentBookingsAllUsers,
};
}

Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading