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>
This commit is contained in:
co-authored by
morgan@cal.com
Morgan
parent
0af62da16b
commit
13e2e340ac
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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