Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75a161fe35 | ||
|
|
eb1ed107ba | ||
|
|
13e2e340ac | ||
|
|
0af62da16b |
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@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-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.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 { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.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 { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.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 { UserAvailabilityService } from "@/lib/services/user-availability.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
@@ -31,6 +33,7 @@ import { Module } from "@nestjs/common";
|
||||
PrismaTeamRepository,
|
||||
RedisService,
|
||||
PrismaFeaturesRepository,
|
||||
PrismaMembershipRepository,
|
||||
CheckBookingLimitsService,
|
||||
CacheService,
|
||||
AvailableSlotsService,
|
||||
@@ -38,6 +41,7 @@ import { Module } from "@nestjs/common";
|
||||
BusyTimesService,
|
||||
FilterHostsService,
|
||||
QualifiedHostsService,
|
||||
NoSlotsNotificationService,
|
||||
],
|
||||
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 { CacheService } from "@/lib/services/cache.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 { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
@@ -35,7 +36,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
checkBookingLimitsService: CheckBookingLimitsService,
|
||||
cacheService: CacheService,
|
||||
userAvailabilityService: UserAvailabilityService,
|
||||
busyTimesService: BusyTimesService
|
||||
busyTimesService: BusyTimesService,
|
||||
noSlotsNotificationService: NoSlotsNotificationService
|
||||
) {
|
||||
super({
|
||||
oooRepo: oooRepoDependency,
|
||||
@@ -53,6 +55,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
busyTimesService,
|
||||
qualifiedHostsService,
|
||||
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. |
|
||||
| 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. |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -9,6 +9,21 @@ function _fireEvent(fullName: string, detail: CustomEventDetail) {
|
||||
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 = {
|
||||
eventTypeSelected: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -24,20 +39,8 @@ export type EventDataMap = {
|
||||
linkReady: Record<string, never>;
|
||||
__connectInitiated: Record<string, never>;
|
||||
__connectCompleted: Record<string, never>;
|
||||
bookingSuccessfulV2: {
|
||||
bookingSuccessfulV2: BaseBookingEventPayload & {
|
||||
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;
|
||||
};
|
||||
rescheduleBookingSuccessfulV2: {
|
||||
rescheduleBookingSuccessfulV2: BaseBookingEventPayload & {
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -57,6 +57,28 @@ export interface IUseBookings {
|
||||
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: {
|
||||
title?: string;
|
||||
startTime: string;
|
||||
@@ -70,18 +92,15 @@ const getBookingSuccessfulEventPayload = (booking: {
|
||||
}) => {
|
||||
return {
|
||||
uid: booking.uid,
|
||||
title: booking.title,
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
eventTypeId: booking.eventTypeId,
|
||||
status: booking.status,
|
||||
paymentRequired: booking.paymentRequired,
|
||||
isRecurring: booking.isRecurring,
|
||||
videoCallUrl: booking.videoCallUrl,
|
||||
...getBaseBookingEventPayload(booking),
|
||||
};
|
||||
};
|
||||
|
||||
const getRescheduleBookingSuccessfulEventPayload = getBookingSuccessfulEventPayload;
|
||||
|
||||
export const getDryRunBookingSuccessfulEventPayload = getBaseBookingEventPayload;
|
||||
|
||||
export const getDryRunRescheduleBookingSuccessfulEventPayload = getDryRunBookingSuccessfulEventPayload;
|
||||
export interface IUseBookingLoadingStates {
|
||||
creatingBooking: boolean;
|
||||
creatingRecurringBooking: boolean;
|
||||
@@ -196,6 +215,30 @@ export const useBookings = ({
|
||||
mutationFn: createBooking,
|
||||
onSuccess: (booking) => {
|
||||
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");
|
||||
return;
|
||||
}
|
||||
@@ -343,6 +386,30 @@ export const useBookings = ({
|
||||
const booking = bookings[0] || {};
|
||||
|
||||
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");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { eventTypeRepositoryModule } from "../modules/EventType";
|
||||
import { featuresRepositoryModule } from "../modules/Features";
|
||||
import { filterHostsModule } from "../modules/FilterHosts";
|
||||
import { getUserAvailabilityModule } from "../modules/GetUserAvailability";
|
||||
import { membershipRepositoryModule } from "../modules/Membership";
|
||||
import { noSlotsNotificationModule } from "../modules/NoSlotsNotification";
|
||||
import { oooRepositoryModule } from "../modules/Ooo";
|
||||
import { qualifiedHostsModule } from "../modules/QualifiedHosts";
|
||||
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.SELECTED_SLOT_REPOSITORY_MODULE, selectedSlotsRepositoryModule);
|
||||
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.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
|
||||
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.FILTER_HOSTS_SERVICE_MODULE, filterHostsModule);
|
||||
container.load(DI_TOKENS.QUALIFIED_HOSTS_SERVICE_MODULE, qualifiedHostsModule);
|
||||
container.load(DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE_MODULE, noSlotsNotificationModule);
|
||||
|
||||
export function getAvailableSlotsService() {
|
||||
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,
|
||||
featuresRepo: DI_TOKENS.FEATURES_REPOSITORY,
|
||||
qualifiedHostsService: DI_TOKENS.QUALIFIED_HOSTS_SERVICE,
|
||||
noSlotsNotificationService: DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE,
|
||||
} 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"),
|
||||
FILTER_HOSTS_SERVICE: Symbol("FilterHostsService"),
|
||||
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 },
|
||||
})) 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;
|
||||
}
|
||||
|
||||
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.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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"description": "Customizable UI components to integrate scheduling into your product.",
|
||||
"authors": "Cal.com, Inc.",
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.0",
|
||||
"scripts": {
|
||||
"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",
|
||||
|
||||
@@ -7,3 +7,4 @@ export { PrismaSelectedSlotRepository } from "@calcom/lib/server/repository/Pris
|
||||
export { TeamRepository as PrismaTeamRepository } from "@calcom/lib/server/repository/team";
|
||||
export { UserRepository as PrismaUserRepository } from "@calcom/lib/server/repository/user";
|
||||
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 { QualifiedHostsService } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials";
|
||||
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";
|
||||
|
||||
export { AvailableSlotsService };
|
||||
@@ -10,3 +11,4 @@ export { BusyTimesService };
|
||||
export { QualifiedHostsService };
|
||||
|
||||
export { FilterHostsService };
|
||||
export { NoSlotsNotificationService };
|
||||
|
||||
@@ -6,8 +6,7 @@ import { vi, describe, it, beforeAll, afterAll, expect, beforeEach, afterEach }
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import * as CalcomEmails from "@calcom/emails";
|
||||
import { RedisService } from "@calcom/features/redis/RedisService";
|
||||
|
||||
import { handleNotificationWhenNoSlots } from "./handleNotificationWhenNoSlots";
|
||||
import { getNoSlotsNotificationService } from "@calcom/lib/di/containers/NoSlotsNotification";
|
||||
|
||||
vi.mock("@calcom/features/redis/RedisService", () => {
|
||||
const mockedRedis = vi.fn();
|
||||
@@ -84,14 +83,16 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
};
|
||||
|
||||
// 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();
|
||||
|
||||
// Mock length to be one then recall to trigger email
|
||||
mocked.lrange.mockResolvedValueOnce([""]);
|
||||
|
||||
await handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
|
||||
const service2 = getNoSlotsNotificationService();
|
||||
await service2.handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
|
||||
expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).toHaveBeenCalled();
|
||||
});
|
||||
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,
|
||||
};
|
||||
|
||||
await handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
|
||||
|
||||
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
|
||||
|
||||
// Call with specific teamId
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({
|
||||
eventDetails,
|
||||
orgDetails,
|
||||
teamId: 123, // specific teamId
|
||||
@@ -205,7 +208,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
|
||||
mocked.lrange.mockResolvedValueOnce([""]);
|
||||
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({
|
||||
eventDetails,
|
||||
orgDetails,
|
||||
// teamId intentionally omitted
|
||||
@@ -233,7 +237,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
|
||||
mocked.lrange.mockResolvedValueOnce([""]);
|
||||
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({
|
||||
eventDetails,
|
||||
orgDetails,
|
||||
teamId: 123,
|
||||
@@ -263,7 +268,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
isValidOrgDomain: true,
|
||||
};
|
||||
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({
|
||||
eventDetails,
|
||||
orgDetails,
|
||||
teamId: 123,
|
||||
@@ -318,7 +324,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
|
||||
mocked.lrange.mockResolvedValueOnce([""]);
|
||||
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({
|
||||
eventDetails,
|
||||
orgDetails,
|
||||
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
|
||||
mocked.lrange.mockResolvedValueOnce([""]); // One previous occurrence
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({
|
||||
eventDetails,
|
||||
orgDetails,
|
||||
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
|
||||
mocked.lrange.mockResolvedValueOnce(["", ""]); // Two occurrences now
|
||||
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service2 = getNoSlotsNotificationService();
|
||||
await service2.handleNotificationWhenNoSlots({
|
||||
eventDetails,
|
||||
orgDetails,
|
||||
teamId: 123,
|
||||
@@ -436,7 +445,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
|
||||
|
||||
// First event type notification
|
||||
mocked.lrange.mockResolvedValueOnce([""]); // Simulate one previous occurrence for first event
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service = getNoSlotsNotificationService();
|
||||
await service.handleNotificationWhenNoSlots({
|
||||
eventDetails: { ...baseEventDetails, eventSlug: "event1" },
|
||||
orgDetails,
|
||||
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
|
||||
// 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
|
||||
await handleNotificationWhenNoSlots({
|
||||
const service2 = getNoSlotsNotificationService();
|
||||
await service2.handleNotificationWhenNoSlots({
|
||||
eventDetails: { ...baseEventDetails, eventSlug: "event2" },
|
||||
orgDetails,
|
||||
teamId: 123,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
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 { 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 = {
|
||||
username: string;
|
||||
@@ -38,107 +39,83 @@ const constructDataHash = (eventDetails: EventDetails) => {
|
||||
return JSON.stringify(obj);
|
||||
};
|
||||
|
||||
export const handleNotificationWhenNoSlots = async ({
|
||||
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;
|
||||
export interface INoSlotsNotificationService {
|
||||
teamRepo: TeamRepository;
|
||||
membershipRepo: MembershipRepository;
|
||||
redisClient: IRedisService;
|
||||
}
|
||||
|
||||
// Check org has this setting enabled
|
||||
const orgSettings = await prisma.team.findFirst({
|
||||
where: {
|
||||
export class NoSlotsNotificationService {
|
||||
constructor(public readonly dependencies: INoSlotsNotificationService) {}
|
||||
|
||||
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,
|
||||
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({
|
||||
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");
|
||||
if (!orgSettings?.organizationSettings?.adminGetsNoSlotsNotification) return;
|
||||
|
||||
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 ?? "",
|
||||
};
|
||||
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 this.dependencies.redisClient.lrange(usersUniqueKey, 0, NO_SLOTS_COUNT_FOR_NOTIFICATION - 1)) ??
|
||||
[];
|
||||
await this.dependencies.redisClient.lpush(usersUniqueKey, constructDataHash(eventDetails));
|
||||
|
||||
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 type { TGetScheduleInputSchema } from "./getSchedule.schema";
|
||||
import { handleNotificationWhenNoSlots } from "./handleNotificationWhenNoSlots";
|
||||
import type { NoSlotsNotificationService } from "./handleNotificationWhenNoSlots";
|
||||
import type { GetScheduleOptions } from "./types";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[slots/util]"] });
|
||||
@@ -108,6 +108,7 @@ export interface IAvailableSlotsService {
|
||||
redisClient: IRedisService;
|
||||
featuresRepo: FeaturesRepository;
|
||||
qualifiedHostsService: QualifiedHostsService;
|
||||
noSlotsNotificationService: NoSlotsNotificationService;
|
||||
}
|
||||
|
||||
function withSlotsCache(
|
||||
@@ -1438,8 +1439,7 @@ export class AvailableSlotsService {
|
||||
// We only want to run this on single targeted events and not dynamic
|
||||
if (!Object.keys(withinBoundsSlotsMappedToDate).length && input.usernameList?.length === 1) {
|
||||
try {
|
||||
// TODO: DI handleNotificationWhenNoSlots
|
||||
await handleNotificationWhenNoSlots({
|
||||
await this.dependencies.noSlotsNotificationService.handleNotificationWhenNoSlots({
|
||||
eventDetails: {
|
||||
username: input.usernameList?.[0],
|
||||
startTime: startTime,
|
||||
|
||||
@@ -2500,7 +2500,7 @@ __metadata:
|
||||
"@axiomhq/winston": ^1.2.0
|
||||
"@calcom/platform-constants": "*"
|
||||
"@calcom/platform-enums": "*"
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.311"
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.312"
|
||||
"@calcom/platform-types": "*"
|
||||
"@calcom/platform-utils": "*"
|
||||
"@calcom/prisma": "*"
|
||||
@@ -3559,13 +3559,13 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.311":
|
||||
version: 0.0.311
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.311"
|
||||
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.312":
|
||||
version: 0.0.312
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.312"
|
||||
dependencies:
|
||||
"@calcom/features": "*"
|
||||
"@calcom/lib": "*"
|
||||
checksum: 7a38e8e0478dfe163a97ddab89285b98d7c7e86a73d3659bc29e31c059782708bfe2466e50657f6861e1c67bcfc66e1e3e44803ab3b5f65fd0fe656e880bb916
|
||||
checksum: 46520737404a61f3aa96c6f1adfa81e67f645fd984f495662ca7c63184b0310117a1dad57ddbe7ea487f2e5a0c9cbe62749a9a43b3197a021b19e3ccadc1f5b2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user