diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index ba7222ed8c..462678e01f 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -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": "*", diff --git a/apps/api/v2/src/lib/modules/available-slots.module.ts b/apps/api/v2/src/lib/modules/available-slots.module.ts index c6a0f26f67..8a30a8b140 100644 --- a/apps/api/v2/src/lib/modules/available-slots.module.ts +++ b/apps/api/v2/src/lib/modules/available-slots.module.ts @@ -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], }) diff --git a/apps/api/v2/src/lib/repositories/prisma-membership.repository.ts b/apps/api/v2/src/lib/repositories/prisma-membership.repository.ts new file mode 100644 index 0000000000..a535ba2c79 --- /dev/null +++ b/apps/api/v2/src/lib/repositories/prisma-membership.repository.ts @@ -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); + } +} diff --git a/apps/api/v2/src/lib/services/available-slots.service.ts b/apps/api/v2/src/lib/services/available-slots.service.ts index 6f2e33cf4e..0ac997c01d 100644 --- a/apps/api/v2/src/lib/services/available-slots.service.ts +++ b/apps/api/v2/src/lib/services/available-slots.service.ts @@ -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, }); } } diff --git a/apps/api/v2/src/lib/services/no-slots-notification.service.ts b/apps/api/v2/src/lib/services/no-slots-notification.service.ts new file mode 100644 index 0000000000..12a387012f --- /dev/null +++ b/apps/api/v2/src/lib/services/no-slots-notification.service.ts @@ -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, + }); + } +} diff --git a/packages/lib/di/containers/AvailableSlots.ts b/packages/lib/di/containers/AvailableSlots.ts index 7def9ba7fd..ca858ea970 100644 --- a/packages/lib/di/containers/AvailableSlots.ts +++ b/packages/lib/di/containers/AvailableSlots.ts @@ -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(DI_TOKENS.AVAILABLE_SLOTS_SERVICE); diff --git a/packages/lib/di/containers/NoSlotsNotification.ts b/packages/lib/di/containers/NoSlotsNotification.ts new file mode 100644 index 0000000000..dd35e281b7 --- /dev/null +++ b/packages/lib/di/containers/NoSlotsNotification.ts @@ -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(DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE); +} diff --git a/packages/lib/di/modules/AvailableSlots.ts b/packages/lib/di/modules/AvailableSlots.ts index 727c7b614a..cdd971462b 100644 --- a/packages/lib/di/modules/AvailableSlots.ts +++ b/packages/lib/di/modules/AvailableSlots.ts @@ -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); diff --git a/packages/lib/di/modules/Membership.ts b/packages/lib/di/modules/Membership.ts new file mode 100644 index 0000000000..b907f341d0 --- /dev/null +++ b/packages/lib/di/modules/Membership.ts @@ -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]); diff --git a/packages/lib/di/modules/NoSlotsNotification.ts b/packages/lib/di/modules/NoSlotsNotification.ts new file mode 100644 index 0000000000..baa99436a7 --- /dev/null +++ b/packages/lib/di/modules/NoSlotsNotification.ts @@ -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); diff --git a/packages/lib/di/tokens.ts b/packages/lib/di/tokens.ts index 944ca851ef..83aaa79c48 100644 --- a/packages/lib/di/tokens.ts +++ b/packages/lib/di/tokens.ts @@ -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"), }; diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index 82857765ad..7a207afb1b 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -460,4 +460,28 @@ export class MembershipRepository { select: select ?? { userId: true }, })) as unknown as Promise[]>; } + + 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, + }, + }, + }, + }); + } } diff --git a/packages/lib/server/repository/team.ts b/packages/lib/server/repository/team.ts index 8872b98f0d..984c5fed93 100644 --- a/packages/lib/server/repository/team.ts +++ b/packages/lib/server/repository/team.ts @@ -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, + }, + }); + } } diff --git a/packages/platform/libraries/repositories.ts b/packages/platform/libraries/repositories.ts index ee847613b6..1fea51735e 100644 --- a/packages/platform/libraries/repositories.ts +++ b/packages/platform/libraries/repositories.ts @@ -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"; diff --git a/packages/platform/libraries/slots.ts b/packages/platform/libraries/slots.ts index 35bf208e2a..94a0dac056 100644 --- a/packages/platform/libraries/slots.ts +++ b/packages/platform/libraries/slots.ts @@ -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 }; diff --git a/packages/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots.test.ts b/packages/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots.test.ts index 01f5fd010a..cf4043a1f2 100644 --- a/packages/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots.test.ts +++ b/packages/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots.test.ts @@ -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, diff --git a/packages/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots.ts b/packages/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots.ts index 0318cbff37..abadbba087 100644 --- a/packages/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots.ts +++ b/packages/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots.ts @@ -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> = []; - // 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> = []; + // 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); } -}; +} diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index aafc904bad..e705a54530 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -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, diff --git a/yarn.lock b/yarn.lock index 7322b3451e..d6513632a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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