Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75a161fe35 | ||
|
|
eb1ed107ba | ||
|
|
13e2e340ac | ||
|
|
0af62da16b |
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>);
|
||||||
|
|||||||
@@ -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>);
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user