Compare commits

...
Author SHA1 Message Date
github-actions[bot] 75a161fe35 chore: version packages (#23120)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-15 13:13:03 +00:00
Morgan eb1ed107ba chore: bump atoms changeset silentlyHandleCalendarFailures (#23119)
* chore: bump atoms changeset silentlyHandleCalendarFailures

* update dock booker

* fixup! update dock booker
2025-08-15 12:40:10 +00:00
13e2e340ac refactor: convert handleNotificationWhenNoSlots to service class with DI (#23055)
* refactor: convert handleNotificationWhenNoSlots to service class with DI

- Convert standalone handleNotificationWhenNoSlots function to NoSlotsNotificationService class
- Add INoSlotsNotificationService interface following existing patterns
- Create NoSlotsNotification DI module and add to container
- Add NO_SLOTS_NOTIFICATION_SERVICE tokens to DI_TOKENS
- Inject service into AvailableSlotsService dependencies
- Update AvailableSlotsService to use injected service instead of direct function call
- Update all test cases to use service class pattern
- Follows existing DI patterns used by other services like BusyTimesService and CheckBookingLimitsService

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* refactor: move Prisma calls to repositories and create dedicated DI container

- Add findOrganizationSettingsBySlug and findTeamSlugById methods to TeamRepository
- Add findTeamAdminsByTeamId method to MembershipRepository
- Create MembershipRepository DI module and tokens
- Update NoSlotsNotificationService to inject TeamRepository, MembershipRepository, and Redis client
- Create dedicated DI container for NoSlotsNotificationService
- Update AvailableSlots DI container to include MembershipRepository
- Replace direct Prisma and Redis calls with repository methods and injected client
- Update tests to use DI container instead of direct service instantiation
- Fix Redis interface import path

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* chore: DI noSlotsNotification service

* chore: bump library

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-08-15 08:30:04 -03:00
0af62da16b feat: add dry run events for SDK (dryRunBookingSuccessfulV2 and dryRunRescheduleBookingSuccessfulV2) (#23072)
* feat: add dry run events for SDK (dryRunBookingSuccessfulV2 and dryRunRescheduleBookingSuccessfulV2)

- Add new event types dryRunBookingSuccessfulV2 and dryRunRescheduleBookingSuccessfulV2 to EventDataMap
- Create payload functions that exclude uid field for dry run events
- Fire dry run events when isDryRun is true in both regular and recurring booking flows
- Add unit tests to verify dry run event payloads are correct and exclude uid field
- Ensures dry run mode no longer skips event firing but uses appropriate dry run variants

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: make dry run events more DRY by reusing types and payload logic

- Create BaseBookingEventPayload type to eliminate duplication between regular and dry run events
- Use intersection types to compose final event types in sdk-action-manager.ts
- Create getBaseBookingEventPayload function for common payload generation logic
- Export dry run payload functions and import them in tests instead of redefining
- Fix missing videoCallUrl field in rescheduleBookingSuccessfulV2 type

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Remove test file

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-08-15 05:21:08 +00:00
24 changed files with 358 additions and 156 deletions
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0", "@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*", "@calcom/platform-constants": "*",
"@calcom/platform-enums": "*", "@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.311", "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.312",
"@calcom/platform-types": "*", "@calcom/platform-types": "*",
"@calcom/platform-utils": "*", "@calcom/platform-utils": "*",
"@calcom/prisma": "*", "@calcom/prisma": "*",
@@ -1,6 +1,7 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository"; import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository"; import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { PrismaMembershipRepository } from "@/lib/repositories/prisma-membership.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository"; import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository"; import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository"; import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
@@ -12,6 +13,7 @@ import { BusyTimesService } from "@/lib/services/busy-times.service";
import { CacheService } from "@/lib/services/cache.service"; import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service"; import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { FilterHostsService } from "@/lib/services/filter-hosts.service"; import { FilterHostsService } from "@/lib/services/filter-hosts.service";
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service"; import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
import { UserAvailabilityService } from "@/lib/services/user-availability.service"; import { UserAvailabilityService } from "@/lib/services/user-availability.service";
import { PrismaModule } from "@/modules/prisma/prisma.module"; import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -31,6 +33,7 @@ import { Module } from "@nestjs/common";
PrismaTeamRepository, PrismaTeamRepository,
RedisService, RedisService,
PrismaFeaturesRepository, PrismaFeaturesRepository,
PrismaMembershipRepository,
CheckBookingLimitsService, CheckBookingLimitsService,
CacheService, CacheService,
AvailableSlotsService, AvailableSlotsService,
@@ -38,6 +41,7 @@ import { Module } from "@nestjs/common";
BusyTimesService, BusyTimesService,
FilterHostsService, FilterHostsService,
QualifiedHostsService, QualifiedHostsService,
NoSlotsNotificationService,
], ],
exports: [AvailableSlotsService], exports: [AvailableSlotsService],
}) })
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaMembershipRepository as PrismaMembershipRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaMembershipRepository extends PrismaMembershipRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -10,6 +10,7 @@ import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"
import { BusyTimesService } from "@/lib/services/busy-times.service"; import { BusyTimesService } from "@/lib/services/busy-times.service";
import { CacheService } from "@/lib/services/cache.service"; import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service"; import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service"; import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
import { RedisService } from "@/modules/redis/redis.service"; import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
@@ -35,7 +36,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
checkBookingLimitsService: CheckBookingLimitsService, checkBookingLimitsService: CheckBookingLimitsService,
cacheService: CacheService, cacheService: CacheService,
userAvailabilityService: UserAvailabilityService, userAvailabilityService: UserAvailabilityService,
busyTimesService: BusyTimesService busyTimesService: BusyTimesService,
noSlotsNotificationService: NoSlotsNotificationService
) { ) {
super({ super({
oooRepo: oooRepoDependency, oooRepo: oooRepoDependency,
@@ -53,6 +55,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
busyTimesService, busyTimesService,
qualifiedHostsService, qualifiedHostsService,
featuresRepo: featuresRepository, featuresRepo: featuresRepository,
noSlotsNotificationService,
}); });
} }
} }
@@ -0,0 +1,21 @@
import { PrismaMembershipRepository } from "@/lib/repositories/prisma-membership.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { NoSlotsNotificationService as BaseNoSlotsNotificationService } from "@calcom/platform-libraries/slots";
@Injectable()
export class NoSlotsNotificationService extends BaseNoSlotsNotificationService {
constructor(
teamRepository: PrismaTeamRepository,
membershipRepository: PrismaMembershipRepository,
redisService: RedisService
) {
super({
teamRepo: teamRepository,
membershipRepo: membershipRepository,
redisClient: redisService,
});
}
}
+1
View File
@@ -128,6 +128,7 @@ Below is a list of props that can be passed to the booker atom.
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. | | onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. | | roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
| showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. | | showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. |
| silentlyHandleCalendarFailures | No | Boolean when true the booker still displays slots when the third party calendars credentials are invalid or expired, Booker may show stale availability when enabled |
## Styling ## Styling
@@ -9,6 +9,21 @@ function _fireEvent(fullName: string, detail: CustomEventDetail) {
window.dispatchEvent(event); window.dispatchEvent(event);
} }
type BaseBookingEventPayload = {
title: string | undefined;
startTime: string | undefined;
endTime: string | undefined;
eventTypeId: number | null | undefined;
status: string | undefined;
paymentRequired: boolean;
isRecurring: boolean;
/**
* This is only used for recurring bookings
*/
allBookings?: { startTime: string; endTime: string }[];
videoCallUrl?: string;
};
export type EventDataMap = { export type EventDataMap = {
eventTypeSelected: { eventTypeSelected: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -24,20 +39,8 @@ export type EventDataMap = {
linkReady: Record<string, never>; linkReady: Record<string, never>;
__connectInitiated: Record<string, never>; __connectInitiated: Record<string, never>;
__connectCompleted: Record<string, never>; __connectCompleted: Record<string, never>;
bookingSuccessfulV2: { bookingSuccessfulV2: BaseBookingEventPayload & {
uid: string | undefined; uid: string | undefined;
title: string | undefined;
startTime: string | undefined;
endTime: string | undefined;
eventTypeId: number | null | undefined;
status: string | undefined;
paymentRequired: boolean;
isRecurring: boolean;
/**
* This is only used for recurring bookings
*/
allBookings?: { startTime: string; endTime: string }[];
videoCallUrl?: string;
}; };
/** /**
@@ -56,20 +59,11 @@ export type EventDataMap = {
}; };
confirmed: boolean; confirmed: boolean;
}; };
rescheduleBookingSuccessfulV2: { rescheduleBookingSuccessfulV2: BaseBookingEventPayload & {
uid: string | undefined; uid: string | undefined;
title: string | undefined;
startTime: string | undefined;
endTime: string | undefined;
eventTypeId: number | null | undefined;
status: string | undefined;
paymentRequired: boolean;
isRecurring: boolean;
/**
* This is only used for recurring bookings
*/
allBookings?: { startTime: string; endTime: string }[];
}; };
dryRunBookingSuccessfulV2: BaseBookingEventPayload;
dryRunRescheduleBookingSuccessfulV2: BaseBookingEventPayload;
/** /**
* @deprecated Use `rescheduleBookingSuccessfulV2` instead. We restrict the data heavily there, only sending what is absolutely needed and keeping it light as well. Plus, more importantly that can be documented well. * @deprecated Use `rescheduleBookingSuccessfulV2` instead. We restrict the data heavily there, only sending what is absolutely needed and keeping it light as well. Plus, more importantly that can be documented well.
*/ */
@@ -57,6 +57,28 @@ export interface IUseBookings {
isBookingDryRun?: boolean; isBookingDryRun?: boolean;
} }
const getBaseBookingEventPayload = (booking: {
title?: string;
startTime: string;
endTime: string;
eventTypeId?: number | null;
status?: BookingStatus;
paymentRequired: boolean;
isRecurring: boolean;
videoCallUrl?: string;
}) => {
return {
title: booking.title,
startTime: booking.startTime,
endTime: booking.endTime,
eventTypeId: booking.eventTypeId,
status: booking.status,
paymentRequired: booking.paymentRequired,
isRecurring: booking.isRecurring,
videoCallUrl: booking.videoCallUrl,
};
};
const getBookingSuccessfulEventPayload = (booking: { const getBookingSuccessfulEventPayload = (booking: {
title?: string; title?: string;
startTime: string; startTime: string;
@@ -70,18 +92,15 @@ const getBookingSuccessfulEventPayload = (booking: {
}) => { }) => {
return { return {
uid: booking.uid, uid: booking.uid,
title: booking.title, ...getBaseBookingEventPayload(booking),
startTime: booking.startTime,
endTime: booking.endTime,
eventTypeId: booking.eventTypeId,
status: booking.status,
paymentRequired: booking.paymentRequired,
isRecurring: booking.isRecurring,
videoCallUrl: booking.videoCallUrl,
}; };
}; };
const getRescheduleBookingSuccessfulEventPayload = getBookingSuccessfulEventPayload; const getRescheduleBookingSuccessfulEventPayload = getBookingSuccessfulEventPayload;
export const getDryRunBookingSuccessfulEventPayload = getBaseBookingEventPayload;
export const getDryRunRescheduleBookingSuccessfulEventPayload = getDryRunBookingSuccessfulEventPayload;
export interface IUseBookingLoadingStates { export interface IUseBookingLoadingStates {
creatingBooking: boolean; creatingBooking: boolean;
creatingRecurringBooking: boolean; creatingRecurringBooking: boolean;
@@ -196,6 +215,30 @@ export const useBookings = ({
mutationFn: createBooking, mutationFn: createBooking,
onSuccess: (booking) => { onSuccess: (booking) => {
if (booking.isDryRun) { if (booking.isDryRun) {
const validDuration = event.data?.isDynamic
? duration || event.data?.length
: duration && event.data?.metadata?.multipleDuration?.includes(duration)
? duration
: event.data?.length;
if (isRescheduling) {
sdkActionManager?.fire(
"dryRunRescheduleBookingSuccessfulV2",
getDryRunRescheduleBookingSuccessfulEventPayload({
...booking,
isRecurring: false,
})
);
} else {
sdkActionManager?.fire(
"dryRunBookingSuccessfulV2",
getDryRunBookingSuccessfulEventPayload({
...booking,
isRecurring: false,
})
);
}
router.push("/booking/dry-run-successful"); router.push("/booking/dry-run-successful");
return; return;
} }
@@ -343,6 +386,30 @@ export const useBookings = ({
const booking = bookings[0] || {}; const booking = bookings[0] || {};
if (booking.isDryRun) { if (booking.isDryRun) {
if (isRescheduling) {
sdkActionManager?.fire("dryRunRescheduleBookingSuccessfulV2", {
...getDryRunRescheduleBookingSuccessfulEventPayload({
...booking,
isRecurring: true,
}),
allBookings: bookings.map((booking) => ({
startTime: booking.startTime,
endTime: booking.endTime,
})),
});
} else {
sdkActionManager?.fire("dryRunBookingSuccessfulV2", {
...getDryRunBookingSuccessfulEventPayload({
...booking,
isRecurring: true,
}),
allBookings: bookings.map((booking) => ({
startTime: booking.startTime,
endTime: booking.endTime,
})),
});
}
router.push("/booking/dry-run-successful"); router.push("/booking/dry-run-successful");
return; return;
} }
@@ -14,6 +14,8 @@ import { eventTypeRepositoryModule } from "../modules/EventType";
import { featuresRepositoryModule } from "../modules/Features"; import { featuresRepositoryModule } from "../modules/Features";
import { filterHostsModule } from "../modules/FilterHosts"; import { filterHostsModule } from "../modules/FilterHosts";
import { getUserAvailabilityModule } from "../modules/GetUserAvailability"; import { getUserAvailabilityModule } from "../modules/GetUserAvailability";
import { membershipRepositoryModule } from "../modules/Membership";
import { noSlotsNotificationModule } from "../modules/NoSlotsNotification";
import { oooRepositoryModule } from "../modules/Ooo"; import { oooRepositoryModule } from "../modules/Ooo";
import { qualifiedHostsModule } from "../modules/QualifiedHosts"; import { qualifiedHostsModule } from "../modules/QualifiedHosts";
import { routingFormResponseRepositoryModule } from "../modules/RoutingFormResponse"; import { routingFormResponseRepositoryModule } from "../modules/RoutingFormResponse";
@@ -29,6 +31,7 @@ container.load(DI_TOKENS.OOO_REPOSITORY_MODULE, oooRepositoryModule);
container.load(DI_TOKENS.SCHEDULE_REPOSITORY_MODULE, scheduleRepositoryModule); container.load(DI_TOKENS.SCHEDULE_REPOSITORY_MODULE, scheduleRepositoryModule);
container.load(DI_TOKENS.SELECTED_SLOT_REPOSITORY_MODULE, selectedSlotsRepositoryModule); container.load(DI_TOKENS.SELECTED_SLOT_REPOSITORY_MODULE, selectedSlotsRepositoryModule);
container.load(DI_TOKENS.TEAM_REPOSITORY_MODULE, teamRepositoryModule); container.load(DI_TOKENS.TEAM_REPOSITORY_MODULE, teamRepositoryModule);
container.load(DI_TOKENS.MEMBERSHIP_REPOSITORY_MODULE, membershipRepositoryModule);
container.load(DI_TOKENS.USER_REPOSITORY_MODULE, userRepositoryModule); container.load(DI_TOKENS.USER_REPOSITORY_MODULE, userRepositoryModule);
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule); container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
container.load(DI_TOKENS.EVENT_TYPE_REPOSITORY_MODULE, eventTypeRepositoryModule); container.load(DI_TOKENS.EVENT_TYPE_REPOSITORY_MODULE, eventTypeRepositoryModule);
@@ -42,6 +45,7 @@ container.load(DI_TOKENS.BUSY_TIMES_SERVICE_MODULE, busyTimesModule);
container.load(DI_TOKENS.BUSY_TIMES_SERVICE_MODULE, busyTimesModule); container.load(DI_TOKENS.BUSY_TIMES_SERVICE_MODULE, busyTimesModule);
container.load(DI_TOKENS.FILTER_HOSTS_SERVICE_MODULE, filterHostsModule); container.load(DI_TOKENS.FILTER_HOSTS_SERVICE_MODULE, filterHostsModule);
container.load(DI_TOKENS.QUALIFIED_HOSTS_SERVICE_MODULE, qualifiedHostsModule); container.load(DI_TOKENS.QUALIFIED_HOSTS_SERVICE_MODULE, qualifiedHostsModule);
container.load(DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE_MODULE, noSlotsNotificationModule);
export function getAvailableSlotsService() { export function getAvailableSlotsService() {
return container.get<AvailableSlotsService>(DI_TOKENS.AVAILABLE_SLOTS_SERVICE); return container.get<AvailableSlotsService>(DI_TOKENS.AVAILABLE_SLOTS_SERVICE);
@@ -0,0 +1,21 @@
import { createContainer } from "@evyweb/ioctopus";
import { redisModule } from "@calcom/features/redis/di/redisModule";
import { DI_TOKENS } from "@calcom/lib/di/tokens";
import { prismaModule } from "@calcom/prisma/prisma.module";
import type { NoSlotsNotificationService } from "@calcom/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots";
import { membershipRepositoryModule } from "../modules/Membership";
import { noSlotsNotificationModule } from "../modules/NoSlotsNotification";
import { teamRepositoryModule } from "../modules/Team";
const container = createContainer();
container.load(DI_TOKENS.REDIS_CLIENT, redisModule);
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
container.load(DI_TOKENS.TEAM_REPOSITORY_MODULE, teamRepositoryModule);
container.load(DI_TOKENS.MEMBERSHIP_REPOSITORY_MODULE, membershipRepositoryModule);
container.load(DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE_MODULE, noSlotsNotificationModule);
export function getNoSlotsNotificationService() {
return container.get<NoSlotsNotificationService>(DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE);
}
@@ -22,4 +22,5 @@ availableSlotsModule.bind(DI_TOKENS.AVAILABLE_SLOTS_SERVICE).toClass(AvailableSl
busyTimesService: DI_TOKENS.BUSY_TIMES_SERVICE, busyTimesService: DI_TOKENS.BUSY_TIMES_SERVICE,
featuresRepo: DI_TOKENS.FEATURES_REPOSITORY, featuresRepo: DI_TOKENS.FEATURES_REPOSITORY,
qualifiedHostsService: DI_TOKENS.QUALIFIED_HOSTS_SERVICE, qualifiedHostsService: DI_TOKENS.QUALIFIED_HOSTS_SERVICE,
noSlotsNotificationService: DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE,
} satisfies Record<keyof IAvailableSlotsService, symbol>); } satisfies Record<keyof IAvailableSlotsService, symbol>);
+9
View File
@@ -0,0 +1,9 @@
import { createModule } from "@evyweb/ioctopus";
import { DI_TOKENS } from "@calcom/lib/di/tokens";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
export const membershipRepositoryModule = createModule();
membershipRepositoryModule
.bind(DI_TOKENS.MEMBERSHIP_REPOSITORY)
.toClass(MembershipRepository, [DI_TOKENS.PRISMA_CLIENT]);
@@ -0,0 +1,13 @@
import { createModule } from "@evyweb/ioctopus";
import type { INoSlotsNotificationService } from "@calcom/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots";
import { NoSlotsNotificationService } from "@calcom/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots";
import { DI_TOKENS } from "../tokens";
export const noSlotsNotificationModule = createModule();
noSlotsNotificationModule.bind(DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE).toClass(NoSlotsNotificationService, {
teamRepo: DI_TOKENS.TEAM_REPOSITORY,
membershipRepo: DI_TOKENS.MEMBERSHIP_REPOSITORY,
redisClient: DI_TOKENS.REDIS_CLIENT,
} satisfies Record<keyof INoSlotsNotificationService, symbol>);
+4
View File
@@ -41,4 +41,8 @@ export const DI_TOKENS = {
QUALIFIED_HOSTS_SERVICE_MODULE: Symbol("QualifiedHostsServiceModule"), QUALIFIED_HOSTS_SERVICE_MODULE: Symbol("QualifiedHostsServiceModule"),
FILTER_HOSTS_SERVICE: Symbol("FilterHostsService"), FILTER_HOSTS_SERVICE: Symbol("FilterHostsService"),
FILTER_HOSTS_SERVICE_MODULE: Symbol("FilterHostsServiceModule"), FILTER_HOSTS_SERVICE_MODULE: Symbol("FilterHostsServiceModule"),
NO_SLOTS_NOTIFICATION_SERVICE: Symbol("NoSlotsNotificationService"),
NO_SLOTS_NOTIFICATION_SERVICE_MODULE: Symbol("NoSlotsNotificationServiceModule"),
MEMBERSHIP_REPOSITORY: Symbol("MembershipRepository"),
MEMBERSHIP_REPOSITORY_MODULE: Symbol("MembershipRepositoryModule"),
}; };
@@ -460,4 +460,28 @@ export class MembershipRepository {
select: select ?? { userId: true }, select: select ?? { userId: true },
})) as unknown as Promise<MembershipDTOFromSelect<TSelect>[]>; })) as unknown as Promise<MembershipDTOFromSelect<TSelect>[]>;
} }
async findTeamAdminsByTeamId({ teamId }: { teamId: number }) {
return await this.prismaClient.membership.findMany({
where: {
team: {
id: teamId,
parentId: {
not: null,
},
},
role: {
in: ["ADMIN", "OWNER"],
},
},
select: {
user: {
select: {
email: true,
locale: true,
},
},
},
});
}
} }
+27
View File
@@ -350,4 +350,31 @@ export class TeamRepository {
return team?.parent; return team?.parent;
} }
async findOrganizationSettingsBySlug({ slug }: { slug: string }) {
return await this.prismaClient.team.findFirst({
where: {
slug,
isOrganization: true,
},
select: {
organizationSettings: {
select: {
adminGetsNoSlotsNotification: true,
},
},
},
});
}
async findTeamSlugById({ id }: { id: number }) {
return await this.prismaClient.team.findUnique({
where: {
id,
},
select: {
slug: true,
},
});
}
} }
+6
View File
@@ -1,5 +1,11 @@
## 1.1.2 ## 1.1.2
## 1.4.0
### Minor Changes
- [#23119](https://github.com/calcom/cal.com/pull/23119) [`eb1ed10`](https://github.com/calcom/cal.com/commit/eb1ed107ba54a4ae99d0b3aa741ae3076d0ec629) Thanks [@ThyMinimalDev](https://github.com/ThyMinimalDev)! - Added Booker atom prop silentlyHandleCalendarFailures, which ensure the booker still displays slots when the third party calendars credentials are invalid or expired
## 1.3.1 ## 1.3.1
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module", "type": "module",
"description": "Customizable UI components to integrate scheduling into your product.", "description": "Customizable UI components to integrate scheduling into your product.",
"authors": "Cal.com, Inc.", "authors": "Cal.com, Inc.",
"version": "1.3.1", "version": "1.4.0",
"scripts": { "scripts": {
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch", "dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client", "build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
@@ -7,3 +7,4 @@ export { PrismaSelectedSlotRepository } from "@calcom/lib/server/repository/Pris
export { TeamRepository as PrismaTeamRepository } from "@calcom/lib/server/repository/team"; export { TeamRepository as PrismaTeamRepository } from "@calcom/lib/server/repository/team";
export { UserRepository as PrismaUserRepository } from "@calcom/lib/server/repository/user"; export { UserRepository as PrismaUserRepository } from "@calcom/lib/server/repository/user";
export { FeaturesRepository as PrismaFeaturesRepository } from "@calcom/features/flags/features.repository"; export { FeaturesRepository as PrismaFeaturesRepository } from "@calcom/features/flags/features.repository";
export { MembershipRepository as PrismaMembershipRepository } from "@calcom/lib/server/repository/membership";
+2
View File
@@ -1,6 +1,7 @@
import { FilterHostsService } from "@calcom/lib/bookings/filterHostsBySameRoundRobinHost"; import { FilterHostsService } from "@calcom/lib/bookings/filterHostsBySameRoundRobinHost";
import { QualifiedHostsService } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials"; import { QualifiedHostsService } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials";
import { BusyTimesService } from "@calcom/lib/getBusyTimes"; import { BusyTimesService } from "@calcom/lib/getBusyTimes";
import { NoSlotsNotificationService } from "@calcom/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots";
import { AvailableSlotsService } from "@calcom/trpc/server/routers/viewer/slots/util"; import { AvailableSlotsService } from "@calcom/trpc/server/routers/viewer/slots/util";
export { AvailableSlotsService }; export { AvailableSlotsService };
@@ -10,3 +11,4 @@ export { BusyTimesService };
export { QualifiedHostsService }; export { QualifiedHostsService };
export { FilterHostsService }; export { FilterHostsService };
export { NoSlotsNotificationService };
@@ -6,8 +6,7 @@ import { vi, describe, it, beforeAll, afterAll, expect, beforeEach, afterEach }
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import * as CalcomEmails from "@calcom/emails"; import * as CalcomEmails from "@calcom/emails";
import { RedisService } from "@calcom/features/redis/RedisService"; import { RedisService } from "@calcom/features/redis/RedisService";
import { getNoSlotsNotificationService } from "@calcom/lib/di/containers/NoSlotsNotification";
import { handleNotificationWhenNoSlots } from "./handleNotificationWhenNoSlots";
vi.mock("@calcom/features/redis/RedisService", () => { vi.mock("@calcom/features/redis/RedisService", () => {
const mockedRedis = vi.fn(); const mockedRedis = vi.fn();
@@ -84,14 +83,16 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
}; };
// Call the function with teamId // Call the function with teamId
await handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 }); const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).not.toHaveBeenCalled(); expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).not.toHaveBeenCalled();
// Mock length to be one then recall to trigger email // Mock length to be one then recall to trigger email
mocked.lrange.mockResolvedValueOnce([""]); mocked.lrange.mockResolvedValueOnce([""]);
await handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 }); const service2 = getNoSlotsNotificationService();
await service2.handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).toHaveBeenCalled(); expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).toHaveBeenCalled();
}); });
it("Should not send a notification if the org has them disabled", async () => { it("Should not send a notification if the org has them disabled", async () => {
@@ -113,7 +114,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
isValidOrgDomain: true, isValidOrgDomain: true,
}; };
await handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 }); const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).not.toHaveBeenCalled(); expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).not.toHaveBeenCalled();
}); });
@@ -153,7 +155,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
mocked.lrange.mockResolvedValueOnce([""]); // This will trigger email sending mocked.lrange.mockResolvedValueOnce([""]); // This will trigger email sending
// Call with specific teamId // Call with specific teamId
await handleNotificationWhenNoSlots({ const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails, eventDetails,
orgDetails, orgDetails,
teamId: 123, // specific teamId teamId: 123, // specific teamId
@@ -205,7 +208,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
mocked.lrange.mockResolvedValueOnce([""]); mocked.lrange.mockResolvedValueOnce([""]);
await handleNotificationWhenNoSlots({ const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails, eventDetails,
orgDetails, orgDetails,
// teamId intentionally omitted // teamId intentionally omitted
@@ -233,7 +237,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
mocked.lrange.mockResolvedValueOnce([""]); mocked.lrange.mockResolvedValueOnce([""]);
await handleNotificationWhenNoSlots({ const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails, eventDetails,
orgDetails, orgDetails,
teamId: 123, teamId: 123,
@@ -263,7 +268,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
isValidOrgDomain: true, isValidOrgDomain: true,
}; };
await handleNotificationWhenNoSlots({ const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails, eventDetails,
orgDetails, orgDetails,
teamId: 123, teamId: 123,
@@ -318,7 +324,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
mocked.lrange.mockResolvedValueOnce([""]); mocked.lrange.mockResolvedValueOnce([""]);
await handleNotificationWhenNoSlots({ const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails, eventDetails,
orgDetails, orgDetails,
teamId: 123, teamId: 123,
@@ -375,7 +382,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
// First notification cycle - simulate having one previous occurrence // First notification cycle - simulate having one previous occurrence
mocked.lrange.mockResolvedValueOnce([""]); // One previous occurrence mocked.lrange.mockResolvedValueOnce([""]); // One previous occurrence
await handleNotificationWhenNoSlots({ const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails, eventDetails,
orgDetails, orgDetails,
teamId: 123, teamId: 123,
@@ -391,7 +399,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
// This better simulates the real Redis state after the first notification // This better simulates the real Redis state after the first notification
mocked.lrange.mockResolvedValueOnce(["", ""]); // Two occurrences now mocked.lrange.mockResolvedValueOnce(["", ""]); // Two occurrences now
await handleNotificationWhenNoSlots({ const service2 = getNoSlotsNotificationService();
await service2.handleNotificationWhenNoSlots({
eventDetails, eventDetails,
orgDetails, orgDetails,
teamId: 123, teamId: 123,
@@ -436,7 +445,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
// First event type notification // First event type notification
mocked.lrange.mockResolvedValueOnce([""]); // Simulate one previous occurrence for first event mocked.lrange.mockResolvedValueOnce([""]); // Simulate one previous occurrence for first event
await handleNotificationWhenNoSlots({ const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails: { ...baseEventDetails, eventSlug: "event1" }, eventDetails: { ...baseEventDetails, eventSlug: "event1" },
orgDetails, orgDetails,
teamId: 123, teamId: 123,
@@ -451,7 +461,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
// For the second event type, also simulate one previous occurrence // For the second event type, also simulate one previous occurrence
// This needs to be a separate mock since it's a different key in Redis // This needs to be a separate mock since it's a different key in Redis
mocked.lrange.mockResolvedValueOnce([""]); // Simulate one previous occurrence for second event mocked.lrange.mockResolvedValueOnce([""]); // Simulate one previous occurrence for second event
await handleNotificationWhenNoSlots({ const service2 = getNoSlotsNotificationService();
await service2.handleNotificationWhenNoSlots({
eventDetails: { ...baseEventDetails, eventSlug: "event2" }, eventDetails: { ...baseEventDetails, eventSlug: "event2" },
orgDetails, orgDetails,
teamId: 123, teamId: 123,
@@ -1,9 +1,10 @@
import type { Dayjs } from "@calcom/dayjs"; import type { Dayjs } from "@calcom/dayjs";
import { sendOrganizationAdminNoSlotsNotification } from "@calcom/emails"; import { sendOrganizationAdminNoSlotsNotification } from "@calcom/emails";
import { RedisService } from "@calcom/features/redis/RedisService"; import type { IRedisService } from "@calcom/features/redis/IRedisService";
import { IS_PRODUCTION, WEBAPP_URL } from "@calcom/lib/constants"; import { IS_PRODUCTION, WEBAPP_URL } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n"; import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma"; import type { MembershipRepository } from "@calcom/lib/server/repository/membership";
import type { TeamRepository } from "@calcom/lib/server/repository/team";
type EventDetails = { type EventDetails = {
username: string; username: string;
@@ -38,107 +39,83 @@ const constructDataHash = (eventDetails: EventDetails) => {
return JSON.stringify(obj); return JSON.stringify(obj);
}; };
export const handleNotificationWhenNoSlots = async ({ export interface INoSlotsNotificationService {
eventDetails, teamRepo: TeamRepository;
orgDetails, membershipRepo: MembershipRepository;
teamId, redisClient: IRedisService;
}: { }
eventDetails: EventDetails;
orgDetails: { currentOrgDomain: string | null };
teamId?: number;
}) => {
// Check for org
if (!orgDetails.currentOrgDomain || !teamId) return;
const UPSTASH_ENV_FOUND = process.env.UPSTASH_REDIS_REST_TOKEN && process.env.UPSTASH_REDIS_REST_URL;
if (!UPSTASH_ENV_FOUND) return;
// Check org has this setting enabled export class NoSlotsNotificationService {
const orgSettings = await prisma.team.findFirst({ constructor(public readonly dependencies: INoSlotsNotificationService) {}
where: {
async handleNotificationWhenNoSlots({
eventDetails,
orgDetails,
teamId,
}: {
eventDetails: EventDetails;
orgDetails: { currentOrgDomain: string | null };
teamId?: number;
}) {
// Check for org
if (!orgDetails.currentOrgDomain || !teamId) return;
const UPSTASH_ENV_FOUND = process.env.UPSTASH_REDIS_REST_TOKEN && process.env.UPSTASH_REDIS_REST_URL;
if (!UPSTASH_ENV_FOUND) return;
// Check org has this setting enabled
const orgSettings = await this.dependencies.teamRepo.findOrganizationSettingsBySlug({
slug: orgDetails.currentOrgDomain, slug: orgDetails.currentOrgDomain,
isOrganization: true,
},
select: {
organizationSettings: {
select: {
adminGetsNoSlotsNotification: true,
},
},
},
});
if (!orgSettings?.organizationSettings?.adminGetsNoSlotsNotification) return;
const redis = new RedisService();
const usersUniqueKey = constructRedisKey(eventDetails, orgDetails.currentOrgDomain, teamId);
// Get only the required amount of data so the request is as small as possible
// We may need to get more data and check the startDate occurrence of this
// Not trigger email if the start months are the same
const usersExistingNoSlots =
(await redis.lrange(usersUniqueKey, 0, NO_SLOTS_COUNT_FOR_NOTIFICATION - 1)) ?? [];
await redis.lpush(usersUniqueKey, constructDataHash(eventDetails));
if (!usersExistingNoSlots.length) {
await redis.expire(usersUniqueKey, NO_SLOTS_NOTIFICATION_FREQUENCY);
}
// We add one as we know we just added one to the list - saves us re-fetching the data
if (usersExistingNoSlots.length + 1 === NO_SLOTS_COUNT_FOR_NOTIFICATION) {
// Get all team admins to send the email too
const foundAdmins = await prisma.membership.findMany({
where: {
team: {
id: teamId,
parentId: {
not: null,
},
},
role: {
in: ["ADMIN", "OWNER"],
},
},
select: {
user: {
select: {
email: true,
locale: true,
},
},
},
}); });
const teamSlug = await prisma.team.findUnique({ if (!orgSettings?.organizationSettings?.adminGetsNoSlotsNotification) return;
where: {
id: teamId,
},
select: {
slug: true,
},
});
// TODO: use new tasker as we dont want this blocking loading slots (Just out of scope for this PR)
// Tasker isn't 100% working with emails - will refactor after i have made changes to Tasker in another PR.
const emailsToSend: Array<Promise<void>> = [];
// const tasker = getTasker();
for (const admin of foundAdmins) {
const translation = await getTranslation(admin.user.locale ?? "en", "common");
const payload = { const usersUniqueKey = constructRedisKey(eventDetails, orgDetails.currentOrgDomain, teamId);
language: translation, // Get only the required amount of data so the request is as small as possible
to: { // We may need to get more data and check the startDate occurrence of this
email: admin.user.email, // Not trigger email if the start months are the same
}, const usersExistingNoSlots =
user: eventDetails.username, (await this.dependencies.redisClient.lrange(usersUniqueKey, 0, NO_SLOTS_COUNT_FOR_NOTIFICATION - 1)) ??
slug: eventDetails.eventSlug, [];
startTime: eventDetails.startTime.format("YYYY-MM-DD"), await this.dependencies.redisClient.lpush(usersUniqueKey, constructDataHash(eventDetails));
endTime: eventDetails.endTime.format("YYYY-MM-DD"),
// For now navigate here - when impersonation via parameter has been pushed we will impersonate and then navigate to availability
editLink: `${WEBAPP_URL}/availability?type=team`,
teamSlug: teamSlug?.slug ?? "",
};
emailsToSend.push(sendOrganizationAdminNoSlotsNotification(payload)); if (!usersExistingNoSlots.length) {
await this.dependencies.redisClient.expire(usersUniqueKey, NO_SLOTS_NOTIFICATION_FREQUENCY);
}
// We add one as we know we just added one to the list - saves us re-fetching the data
if (usersExistingNoSlots.length + 1 === NO_SLOTS_COUNT_FOR_NOTIFICATION) {
// Get all team admins to send the email too
const foundAdmins = await this.dependencies.membershipRepo.findTeamAdminsByTeamId({
teamId,
});
const teamSlug = await this.dependencies.teamRepo.findTeamSlugById({
id: teamId,
});
// TODO: use new tasker as we dont want this blocking loading slots (Just out of scope for this PR)
// Tasker isn't 100% working with emails - will refactor after i have made changes to Tasker in another PR.
const emailsToSend: Array<Promise<void>> = [];
// const tasker = getTasker();
for (const admin of foundAdmins) {
const translation = await getTranslation(admin.user.locale ?? "en", "common");
const payload = {
language: translation,
to: {
email: admin.user.email,
},
user: eventDetails.username,
slug: eventDetails.eventSlug,
startTime: eventDetails.startTime.format("YYYY-MM-DD"),
endTime: eventDetails.endTime.format("YYYY-MM-DD"),
// For now navigate here - when impersonation via parameter has been pushed we will impersonate and then navigate to availability
editLink: `${WEBAPP_URL}/availability?type=team`,
teamSlug: teamSlug?.slug ?? "",
};
emailsToSend.push(sendOrganizationAdminNoSlotsNotification(payload));
}
await Promise.all(emailsToSend);
} }
await Promise.all(emailsToSend);
} }
}; }
@@ -61,7 +61,7 @@ import type { CredentialForCalendarService } from "@calcom/types/Credential";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import type { TGetScheduleInputSchema } from "./getSchedule.schema"; import type { TGetScheduleInputSchema } from "./getSchedule.schema";
import { handleNotificationWhenNoSlots } from "./handleNotificationWhenNoSlots"; import type { NoSlotsNotificationService } from "./handleNotificationWhenNoSlots";
import type { GetScheduleOptions } from "./types"; import type { GetScheduleOptions } from "./types";
const log = logger.getSubLogger({ prefix: ["[slots/util]"] }); const log = logger.getSubLogger({ prefix: ["[slots/util]"] });
@@ -108,6 +108,7 @@ export interface IAvailableSlotsService {
redisClient: IRedisService; redisClient: IRedisService;
featuresRepo: FeaturesRepository; featuresRepo: FeaturesRepository;
qualifiedHostsService: QualifiedHostsService; qualifiedHostsService: QualifiedHostsService;
noSlotsNotificationService: NoSlotsNotificationService;
} }
function withSlotsCache( function withSlotsCache(
@@ -1438,8 +1439,7 @@ export class AvailableSlotsService {
// We only want to run this on single targeted events and not dynamic // We only want to run this on single targeted events and not dynamic
if (!Object.keys(withinBoundsSlotsMappedToDate).length && input.usernameList?.length === 1) { if (!Object.keys(withinBoundsSlotsMappedToDate).length && input.usernameList?.length === 1) {
try { try {
// TODO: DI handleNotificationWhenNoSlots await this.dependencies.noSlotsNotificationService.handleNotificationWhenNoSlots({
await handleNotificationWhenNoSlots({
eventDetails: { eventDetails: {
username: input.usernameList?.[0], username: input.usernameList?.[0],
startTime: startTime, startTime: startTime,
+5 -5
View File
@@ -2500,7 +2500,7 @@ __metadata:
"@axiomhq/winston": ^1.2.0 "@axiomhq/winston": ^1.2.0
"@calcom/platform-constants": "*" "@calcom/platform-constants": "*"
"@calcom/platform-enums": "*" "@calcom/platform-enums": "*"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.311" "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.312"
"@calcom/platform-types": "*" "@calcom/platform-types": "*"
"@calcom/platform-utils": "*" "@calcom/platform-utils": "*"
"@calcom/prisma": "*" "@calcom/prisma": "*"
@@ -3559,13 +3559,13 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.311": "@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.312":
version: 0.0.311 version: 0.0.312
resolution: "@calcom/platform-libraries@npm:0.0.311" resolution: "@calcom/platform-libraries@npm:0.0.312"
dependencies: dependencies:
"@calcom/features": "*" "@calcom/features": "*"
"@calcom/lib": "*" "@calcom/lib": "*"
checksum: 7a38e8e0478dfe163a97ddab89285b98d7c7e86a73d3659bc29e31c059782708bfe2466e50657f6861e1c67bcfc66e1e3e44803ab3b5f65fd0fe656e880bb916 checksum: 46520737404a61f3aa96c6f1adfa81e67f645fd984f495662ca7c63184b0310117a1dad57ddbe7ea487f2e5a0c9cbe62749a9a43b3197a021b19e3ccadc1f5b2
languageName: node languageName: node
linkType: hard linkType: hard