From 512002aaef4d5bc4b9b60f1e6a934b1c81ecd5bb Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 6 Feb 2026 10:34:03 +0100 Subject: [PATCH] refactor: replace FeaturesRepository with DI-based feature repositories (#27200) * refactor: replace FeaturesRepository with DI-based feature repositories Migrate from the legacy FeaturesRepository pattern to separate DI-based repositories: - Add checkIfFeatureIsEnabledGlobally to IFeatureRepository - Add getTeamsWithFeatureEnabled to ITeamFeatureRepository - Update CalendarSubscriptionService to use featureRepository, teamFeatureRepository, and userFeatureRepository - Update SelectedCalendarRepository.findNextSubscriptionBatch to filter by teamIds instead of featureIds - Update OnboardingPathService to use DI via getFeatureRepository() - Remove prisma parameter from OnboardingPathService callsites Co-Authored-By: Claude Opus 4.5 * test: update tests to use new DI-based feature repositories Update CalendarSubscriptionService and SelectedCalendarRepository tests to use the new separate repository interfaces: - featureRepository for global feature checks - teamFeatureRepository for team-level feature checks - userFeatureRepository for user-level feature checks Co-Authored-By: Claude Opus 4.5 * fix: update API routes to use DI-based feature repositories Update cron and webhook routes to use the new separate repository interfaces instead of the combined FeaturesRepository. Co-Authored-By: Claude Opus 4.5 * fix: remove prisma arg from getGettingStartedPathWithParams call The OnboardingPathService method no longer requires a prisma argument as it now uses DI containers internally. Co-Authored-By: Claude Opus 4.5 * test: update route tests to expect new DI-based feature repositories Update service instantiation tests to expect featureRepository, teamFeatureRepository, and userFeatureRepository instead of the old featuresRepository. Co-Authored-By: Claude Opus 4.5 * fix: optimize global feature check and add guard in checkForNewSubscriptions - Use targeted select for only `enabled` field in checkIfFeatureIsEnabledGlobally - Add global feature flag guard in checkForNewSubscriptions to avoid unnecessary DB queries and API calls when the cache feature is globally disabled Co-Authored-By: Claude Opus 4.5 * remove redundant comment --------- Co-authored-by: Claude Opus 4.5 --- .../onboarding/organization/layout.tsx | 8 +- .../organization/migrate-members/page.tsx | 13 +- .../organization/migrate-teams/page.tsx | 9 +- .../__tests__/route.test.ts | 4 +- .../api/cron/calendar-subscriptions/route.ts | 14 +- .../[provider]/__tests__/route.test.ts | 4 +- .../calendar-subscription/[provider]/route.ts | 17 +- apps/web/lib/pages/auth/verify-email.ts | 13 +- .../sso/[provider]/getServerSideProps.tsx | 24 +-- .../lib/CalendarSubscriptionService.ts | 175 +++++++----------- .../CalendarSubscriptionService.test.ts | 112 +++++++---- .../features/ee/teams/services/teamService.ts | 7 +- .../CachedTeamFeatureRepository.ts | 8 +- .../repositories/PrismaFeatureRepository.ts | 9 + .../PrismaTeamFeatureRepository.ts | 19 +- .../onboarding/lib/onboarding-path.service.ts | 22 +-- .../SelectedCalendarRepository.interface.ts | 8 +- .../SelectedCalendarRepository.test.ts | 51 +---- .../SelectedCalendarRepository.ts | 19 +- .../viewer/teams/inviteMember/utils.ts | 5 +- .../viewer/teams/resendInvitation.handler.ts | 9 +- 21 files changed, 265 insertions(+), 285 deletions(-) diff --git a/apps/web/app/(use-page-wrapper)/onboarding/organization/layout.tsx b/apps/web/app/(use-page-wrapper)/onboarding/organization/layout.tsx index 8b8314c556..242b300023 100644 --- a/apps/web/app/(use-page-wrapper)/onboarding/organization/layout.tsx +++ b/apps/web/app/(use-page-wrapper)/onboarding/organization/layout.tsx @@ -1,13 +1,11 @@ -import { cookies, headers } from "next/headers"; -import { redirect } from "next/navigation"; - import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils"; import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { prisma } from "@calcom/prisma"; - import { buildLegacyRequest } from "@lib/buildLegacyCtx"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; export default async function OrganizationOnboardingLayout({ children }: { children: React.ReactNode }) { const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); @@ -19,7 +17,7 @@ export default async function OrganizationOnboardingLayout({ children }: { child const userEmail = session.user.email || ""; const userId = session.user.id; - const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(prisma); + const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(); if (!isCompanyEmail(userEmail)) { return redirect(gettingStartedPath); diff --git a/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-members/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-members/page.tsx index 5b93b7feb8..2ab8b2b36b 100644 --- a/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-members/page.tsx +++ b/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-members/page.tsx @@ -1,14 +1,11 @@ -import { cookies, headers } from "next/headers"; -import { redirect } from "next/navigation"; - import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils"; import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { prisma } from "@calcom/prisma"; - import { buildLegacyRequest } from "@lib/buildLegacyCtx"; - +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; import { OrganizationMigrateMembersView } from "~/onboarding/organization/migrate-members/organization-migrate-members-view"; export default async function MigrateMembersPage() { @@ -21,14 +18,16 @@ export default async function MigrateMembersPage() { const userEmail = session.user.email || ""; const userId = session.user.id; - const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(prisma); + const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(); if (!isCompanyEmail(userEmail)) { return redirect(gettingStartedPath); } const userRepository = new UserRepository(prisma); - const isMemberOfOrganization = await userRepository.findIfAMemberOfSomeOrganization({ user: { id: userId } }); + const isMemberOfOrganization = await userRepository.findIfAMemberOfSomeOrganization({ + user: { id: userId }, + }); if (isMemberOfOrganization) { return redirect(gettingStartedPath); diff --git a/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-teams/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-teams/page.tsx index df801856b7..c1e4d37ba0 100644 --- a/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-teams/page.tsx +++ b/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-teams/page.tsx @@ -1,15 +1,12 @@ -import { cookies, headers } from "next/headers"; -import { redirect } from "next/navigation"; - import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils"; import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { prisma } from "@calcom/prisma"; - import { buildLegacyRequest } from "@lib/buildLegacyCtx"; - +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; import { OrganizationMigrateTeamsView } from "~/onboarding/organization/migrate-teams/organization-migrate-teams-view"; type PageProps = { @@ -26,7 +23,7 @@ export default async function MigrateTeamsPage({ searchParams }: PageProps) { const userEmail = session.user.email || ""; const userId = session.user.id; - const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(prisma); + const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(); if (!isCompanyEmail(userEmail)) { return redirect(gettingStartedPath); diff --git a/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts b/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts index cefcb61c54..e09447bbe5 100644 --- a/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts +++ b/apps/web/app/api/cron/calendar-subscriptions/__tests__/route.test.ts @@ -220,7 +220,9 @@ describe("/api/cron/calendar-subscriptions", () => { expect(mockCalendarSubscriptionService).toHaveBeenCalledWith({ adapterFactory: expect.any(Object), selectedCalendarRepository: expect.any(Object), - featuresRepository: expect.any(Object), + featureRepository: expect.any(Object), + teamFeatureRepository: expect.any(Object), + userFeatureRepository: expect.any(Object), calendarSyncService: expect.any(Object), calendarCacheEventService: expect.any(Object), }); diff --git a/apps/web/app/api/cron/calendar-subscriptions/route.ts b/apps/web/app/api/cron/calendar-subscriptions/route.ts index eb91cb3f6c..08b12ebe62 100644 --- a/apps/web/app/api/cron/calendar-subscriptions/route.ts +++ b/apps/web/app/api/cron/calendar-subscriptions/route.ts @@ -1,16 +1,18 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; - +import process from "node:process"; import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService"; import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository"; import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService"; -import { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import { getFeatureRepository } from "@calcom/features/di/containers/FeatureRepository"; +import { getTeamFeatureRepository } from "@calcom/features/di/containers/TeamFeatureRepository"; +import { getUserFeatureRepository } from "@calcom/features/di/containers/UserFeatureRepository"; import { SelectedCalendarRepository } from "@calcom/features/selectedCalendar/repositories/SelectedCalendarRepository"; import { prisma } from "@calcom/prisma"; import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; /** * Cron webhook @@ -39,7 +41,9 @@ async function getHandler(request: NextRequest) { const calendarSubscriptionService = new CalendarSubscriptionService({ adapterFactory: new DefaultAdapterFactory(), selectedCalendarRepository: new SelectedCalendarRepository(prisma), - featuresRepository: new FeaturesRepository(prisma), + featureRepository: getFeatureRepository(), + teamFeatureRepository: getTeamFeatureRepository(), + userFeatureRepository: getUserFeatureRepository(), calendarSyncService, calendarCacheEventService, }); diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts index da7a6c64d0..61baa9cb91 100644 --- a/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts @@ -306,7 +306,9 @@ describe("/api/webhooks/calendar-subscription/[provider]", () => { expect(mockCalendarSubscriptionService).toHaveBeenCalledWith({ adapterFactory: expect.any(Object), selectedCalendarRepository: expect.any(Object), - featuresRepository: expect.any(Object), + featureRepository: expect.any(Object), + teamFeatureRepository: expect.any(Object), + userFeatureRepository: expect.any(Object), calendarSyncService: expect.any(Object), calendarCacheEventService: expect.any(Object), }); diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts index b96699d9bc..a05053b70a 100644 --- a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts @@ -1,7 +1,3 @@ -import type { Params } from "app/_types"; -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; - import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; import type { CalendarSubscriptionProvider } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; @@ -9,11 +5,16 @@ import { CalendarSubscriptionService } from "@calcom/features/calendar-subscript import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository"; import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService"; -import { FeaturesRepository } from "@calcom/features/flags/features.repository"; -import logger from "@calcom/lib/logger"; +import { getFeatureRepository } from "@calcom/features/di/containers/FeatureRepository"; +import { getTeamFeatureRepository } from "@calcom/features/di/containers/TeamFeatureRepository"; +import { getUserFeatureRepository } from "@calcom/features/di/containers/UserFeatureRepository"; import { SelectedCalendarRepository } from "@calcom/features/selectedCalendar/repositories/SelectedCalendarRepository"; +import logger from "@calcom/lib/logger"; import { prisma } from "@calcom/prisma"; import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir"; +import type { Params } from "app/_types"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; const log = logger.getSubLogger({ prefix: ["calendar-webhook"] }); @@ -59,7 +60,9 @@ async function postHandler(request: NextRequest, ctx: { params: Promise const calendarSubscriptionService = new CalendarSubscriptionService({ adapterFactory: new DefaultAdapterFactory(), selectedCalendarRepository: new SelectedCalendarRepository(prisma), - featuresRepository: new FeaturesRepository(prisma), + featureRepository: getFeatureRepository(), + teamFeatureRepository: getTeamFeatureRepository(), + userFeatureRepository: getUserFeatureRepository(), calendarSyncService, calendarCacheEventService, }); diff --git a/apps/web/lib/pages/auth/verify-email.ts b/apps/web/lib/pages/auth/verify-email.ts index b487e60ca6..07246877ed 100644 --- a/apps/web/lib/pages/auth/verify-email.ts +++ b/apps/web/lib/pages/auth/verify-email.ts @@ -1,17 +1,14 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { z } from "zod"; - import dayjs from "@calcom/dayjs"; import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing"; import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container"; import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service"; -import { WEBAPP_URL } from "@calcom/lib/constants"; -import { IS_STRIPE_ENABLED } from "@calcom/lib/constants"; +import { IS_STRIPE_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { prisma } from "@calcom/prisma"; -import { MembershipRole } from "@calcom/prisma/enums"; -import { CreationSource } from "@calcom/prisma/enums"; +import { CreationSource, MembershipRole } from "@calcom/prisma/enums"; import { userMetadata } from "@calcom/prisma/zod-utils"; import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { z } from "zod"; const verifySchema = z.object({ token: z.string(), @@ -174,7 +171,7 @@ export async function handler(req: NextApiRequest, res: NextApiResponse) { await moveUserToMatchingOrg({ email: user.email }); - const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(prisma); + const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(); return res.redirect(`${WEBAPP_URL}${hasCompletedOnboarding ? "/event-types" : gettingStartedPath}`); } diff --git a/apps/web/server/lib/auth/sso/[provider]/getServerSideProps.tsx b/apps/web/server/lib/auth/sso/[provider]/getServerSideProps.tsx index 095c3f22d3..f59d5fb606 100644 --- a/apps/web/server/lib/auth/sso/[provider]/getServerSideProps.tsx +++ b/apps/web/server/lib/auth/sso/[provider]/getServerSideProps.tsx @@ -1,16 +1,16 @@ -import type { GetServerSidePropsContext } from "next"; - +import process from "node:process"; import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml"; import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso"; -import { checkUsername } from "@calcom/features/profile/lib/checkUsername"; import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service"; +import { checkUsername } from "@calcom/features/profile/lib/checkUsername"; import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants"; import { getTrackingFromCookies, type TrackingData } from "@calcom/lib/tracking"; import { prisma } from "@calcom/prisma"; +import type { GetServerSidePropsContext } from "next"; import { z } from "zod"; const Params = z.object({ @@ -20,15 +20,9 @@ const Params = z.object({ }); export const getServerSideProps = async ({ req, query }: GetServerSidePropsContext) => { - - const { - provider: providerParam, - email: emailParam, - username: usernameParam, - } = Params.parse(query); + const { provider: providerParam, email: emailParam, username: usernameParam } = Params.parse(query); const successDestination = await OnboardingPathService.getGettingStartedPathWithParams( - prisma, usernameParam ? { username: usernameParam } : undefined ); @@ -148,8 +142,14 @@ const getStripePremiumUsernameUrl = async ({ allow_promotion_codes: true, metadata: { dubCustomerId: userId, // pass the userId during checkout creation for sales conversion tracking: https://d.to/conversions/stripe - ...(tracking?.googleAds?.gclid && { gclid: tracking.googleAds.gclid, campaignId: tracking.googleAds.campaignId }), - ...(tracking?.linkedInAds?.liFatId && { liFatId: tracking.linkedInAds.liFatId, linkedInCampaignId: tracking.linkedInAds?.campaignId }), + ...(tracking?.googleAds?.gclid && { + gclid: tracking.googleAds.gclid, + campaignId: tracking.googleAds.campaignId, + }), + ...(tracking?.linkedInAds?.liFatId && { + liFatId: tracking.linkedInAds.liFatId, + linkedInCampaignId: tracking.linkedInAds?.campaignId, + }), }, }); diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts index 5bdc116711..84b500e082 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts @@ -9,11 +9,13 @@ import type { } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface"; import type { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; import type { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService"; -import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import type { IFeatureRepository } from "@calcom/features/flags/repositories/PrismaFeatureRepository"; +import type { ITeamFeatureRepository } from "@calcom/features/flags/repositories/PrismaTeamFeatureRepository"; +import type { IUserFeatureRepository } from "@calcom/features/flags/repositories/PrismaUserFeatureRepository"; import type { ISelectedCalendarRepository } from "@calcom/features/selectedCalendar/repositories/SelectedCalendarRepository.interface"; import logger from "@calcom/lib/logger"; -import { metrics } from "@sentry/nextjs"; import type { SelectedCalendar } from "@calcom/prisma/client"; +import { metrics } from "@sentry/nextjs"; // biome-ignore lint/nursery/useExplicitType: logger type is inferred const log = logger.getSubLogger({ prefix: ["CalendarSubscriptionService"] }); @@ -27,7 +29,11 @@ export class CalendarSubscriptionService { private deps: { adapterFactory: AdapterFactory; selectedCalendarRepository: ISelectedCalendarRepository; - featuresRepository: FeaturesRepository; + featureRepository: IFeatureRepository; + teamFeatureRepository: ITeamFeatureRepository; + userFeatureRepository: IUserFeatureRepository & { + checkIfUserHasFeature: (userId: number, slug: string) => Promise; + }; calendarCacheEventService: CalendarCacheEventService; calendarSyncService: CalendarSyncService; } @@ -135,16 +141,12 @@ export class CalendarSubscriptionService { * Process webhook */ // biome-ignore lint/complexity/noExcessiveLinesPerFunction: webhook processing requires multiple steps - async processWebhook( - provider: CalendarSubscriptionProvider, - request: Request - ) { + async processWebhook(provider: CalendarSubscriptionProvider, request: Request) { const startTime = performance.now(); log.debug("processWebhook", { provider }); try { - const calendarSubscriptionAdapter = - this.deps.adapterFactory.get(provider); + const calendarSubscriptionAdapter = this.deps.adapterFactory.get(provider); const isValid = await calendarSubscriptionAdapter.validate(request); if (!isValid) { @@ -154,9 +156,7 @@ export class CalendarSubscriptionService { throw new Error("Invalid webhook request"); } - const channelId = await calendarSubscriptionAdapter.extractChannelId( - request - ); + const channelId = await calendarSubscriptionAdapter.extractChannelId(request); if (!channelId) { metrics.count("calendar.subscription.webhook.missing_channel", 1, { attributes: { provider }, @@ -166,8 +166,7 @@ export class CalendarSubscriptionService { log.debug("Processing webhook", { channelId }); - const selectedCalendar = - await this.deps.selectedCalendarRepository.findByChannelId(channelId); + const selectedCalendar = await this.deps.selectedCalendarRepository.findByChannelId(channelId); if (!selectedCalendar) { metrics.count("calendar.subscription.webhook.skipped", 1, { attributes: { provider, reason: "calendar_not_found" }, @@ -188,19 +187,13 @@ export class CalendarSubscriptionService { attributes: { provider }, }); - metrics.distribution( - "calendar.subscription.webhook.duration_ms", - durationMs, - { - attributes: { provider }, - } - ); + metrics.distribution("calendar.subscription.webhook.duration_ms", durationMs, { + attributes: { provider }, + }); - metrics.distribution( - "calendar.subscription.webhook.events_fetched", - eventsProcessed.eventsFetched, - { attributes: { provider } } - ); + metrics.distribution("calendar.subscription.webhook.events_fetched", eventsProcessed.eventsFetched, { + attributes: { provider }, + }); } catch (error) { const durationMs = performance.now() - startTime; @@ -208,13 +201,9 @@ export class CalendarSubscriptionService { attributes: { provider }, }); - metrics.distribution( - "calendar.subscription.webhook.duration_ms", - durationMs, - { - attributes: { provider, outcome: "error" }, - } - ); + metrics.distribution("calendar.subscription.webhook.duration_ms", durationMs, { + attributes: { provider, outcome: "error" }, + }); log.error("Webhook processing failed", { provider, @@ -236,9 +225,7 @@ export class CalendarSubscriptionService { * @returns Object with counts of events fetched, cached, and synced, plus propagation lag metrics */ // biome-ignore lint/complexity/noExcessiveLinesPerFunction: event processing requires multiple steps - async processEvents( - selectedCalendar: SelectedCalendar - ): Promise<{ + async processEvents(selectedCalendar: SelectedCalendar): Promise<{ eventsFetched: number; eventsCached: number; eventsSynced: number; @@ -261,10 +248,7 @@ export class CalendarSubscriptionService { selectedCalendar.integration as CalendarSubscriptionProvider ); - if ( - !selectedCalendar.credentialId && - !selectedCalendar.delegationCredentialId - ) { + if (!selectedCalendar.credentialId && !selectedCalendar.delegationCredentialId) { log.debug("Selected Calendar doesn't have credentials", { selectedCalendarId: selectedCalendar.id, }); @@ -293,10 +277,7 @@ export class CalendarSubscriptionService { let events: CalendarSubscriptionEvent | null = null; try { - events = await calendarSubscriptionAdapter.fetchEvents( - selectedCalendar, - credential - ); + events = await calendarSubscriptionAdapter.fetchEvents(selectedCalendar, credential); } catch (err) { metrics.count("calendar.subscription.events.fetch.error", 1, { attributes: { provider: selectedCalendar.integration }, @@ -315,87 +296,59 @@ export class CalendarSubscriptionService { result.eventsFetched = events.items.length; - metrics.distribution( - "calendar.subscription.events.fetched", - events.items.length, - { - attributes: { - provider: selectedCalendar.integration, - incremental: !!selectedCalendar.syncToken, - }, - } - ); + metrics.distribution("calendar.subscription.events.fetched", events.items.length, { + attributes: { + provider: selectedCalendar.integration, + incremental: !!selectedCalendar.syncToken, + }, + }); const now = Date.now(); const lagStats = this.calculatePropagationLag(events.items, now); if (lagStats) { result.propagationLagMs = lagStats; - metrics.distribution( - "calendar.subscription.propagation_lag.avg_ms", - lagStats.avg, - { - attributes: { provider: selectedCalendar.integration }, - } - ); - metrics.distribution( - "calendar.subscription.propagation_lag.max_ms", - lagStats.max, - { - attributes: { provider: selectedCalendar.integration }, - } - ); + metrics.distribution("calendar.subscription.propagation_lag.avg_ms", lagStats.avg, { + attributes: { provider: selectedCalendar.integration }, + }); + metrics.distribution("calendar.subscription.propagation_lag.max_ms", lagStats.max, { + attributes: { provider: selectedCalendar.integration }, + }); } await this.deps.selectedCalendarRepository.updateSyncStatus(selectedCalendar.id, { - syncToken: events.syncToken || selectedCalendar.syncToken, - syncedAt: new Date(), - syncErrorAt: null, - syncErrorCount: 0, - } - ); + syncToken: events.syncToken || selectedCalendar.syncToken, + syncedAt: new Date(), + syncErrorAt: null, + syncErrorCount: 0, + }); if (cacheEnabled && cacheEnabledForUser) { log.debug("Caching events", { count: events.items.length }); - await this.deps.calendarCacheEventService.handleEvents(selectedCalendar, events.items ); + await this.deps.calendarCacheEventService.handleEvents(selectedCalendar, events.items); result.eventsCached = events.items.length; - metrics.distribution( - "calendar.subscription.events.cached", - events.items.length, - { - attributes: { provider: selectedCalendar.integration }, - } - ); + metrics.distribution("calendar.subscription.events.cached", events.items.length, { + attributes: { provider: selectedCalendar.integration }, + }); } if (syncEnabled) { log.debug("Syncing events", { count: events.items.length }); - await this.deps.calendarSyncService.handleEvents( - selectedCalendar, - events.items - ); + await this.deps.calendarSyncService.handleEvents(selectedCalendar, events.items); result.eventsSynced = events.items.length; - metrics.distribution( - "calendar.subscription.events.synced", - events.items.length, - { - attributes: { provider: selectedCalendar.integration }, - } - ); + metrics.distribution("calendar.subscription.events.synced", events.items.length, { + attributes: { provider: selectedCalendar.integration }, + }); } - metrics.distribution( - "calendar.subscription.processEvents.duration_ms", - performance.now() - startTime, - { - attributes: { - provider: selectedCalendar.integration, - cache: cacheEnabled && cacheEnabledForUser ? "on" : "off", - sync: syncEnabled ? "on" : "off", - }, - } - ); + metrics.distribution("calendar.subscription.processEvents.duration_ms", performance.now() - startTime, { + attributes: { + provider: selectedCalendar.integration, + cache: cacheEnabled && cacheEnabledForUser ? "on" : "off", + sync: syncEnabled ? "on" : "off", + }, + }); return result; } @@ -440,10 +393,16 @@ export class CalendarSubscriptionService { */ // biome-ignore lint/nursery/useExplicitType: return type is void async checkForNewSubscriptions() { + if (!(await this.isCacheEnabled())) { + return; + } + const teamIds = await this.deps.teamFeatureRepository.getTeamsWithFeatureEnabled( + CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE + ); const rows = await this.deps.selectedCalendarRepository.findNextSubscriptionBatch({ take: 100, integrations: this.deps.adapterFactory.getProviders(), - featureIds: [CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE], + teamIds, genericCalendarSuffixes: this.deps.adapterFactory.getGenericCalendarSuffixes(), }); log.debug("checkForNewSubscriptions", { count: rows.length }); @@ -455,7 +414,7 @@ export class CalendarSubscriptionService { * @returns true if cache is enabled */ async isCacheEnabled(): Promise { - return this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally( + return this.deps.featureRepository.checkIfFeatureIsEnabledGlobally( CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE ); } @@ -465,7 +424,7 @@ export class CalendarSubscriptionService { * @returns true if cache is enabled */ async isCacheEnabledForUser(userId: number): Promise { - return this.deps.featuresRepository.checkIfUserHasFeature( + return this.deps.userFeatureRepository.checkIfUserHasFeature( userId, CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_CACHE_FEATURE ); @@ -476,7 +435,7 @@ export class CalendarSubscriptionService { * @returns true if sync is enabled */ async isSyncEnabled(): Promise { - return this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally( + return this.deps.featureRepository.checkIfFeatureIsEnabledGlobally( CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_SYNC_FEATURE ); } diff --git a/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts b/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts index 774d7daafb..353242a5ab 100644 --- a/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts +++ b/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts @@ -1,6 +1,6 @@ import "../__mocks__/delegationCredential"; -import { describe, test, expect, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; vi.mock("@sentry/nextjs", () => ({ metrics: { @@ -12,10 +12,11 @@ vi.mock("@sentry/nextjs", () => ({ import type { AdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory"; import type { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; import type { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService"; -import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import type { IFeatureRepository } from "@calcom/features/flags/repositories/PrismaFeatureRepository"; +import type { ITeamFeatureRepository } from "@calcom/features/flags/repositories/PrismaTeamFeatureRepository"; +import type { IUserFeatureRepository } from "@calcom/features/flags/repositories/PrismaUserFeatureRepository"; import type { ISelectedCalendarRepository } from "@calcom/features/selectedCalendar/repositories/SelectedCalendarRepository.interface"; import type { SelectedCalendar } from "@calcom/prisma/client"; - import { CalendarSubscriptionService } from "../CalendarSubscriptionService"; const mockSelectedCalendar: SelectedCalendar = { @@ -98,7 +99,11 @@ describe("CalendarSubscriptionService", () => { let service: CalendarSubscriptionService; let mockAdapterFactory: AdapterFactory; let mockSelectedCalendarRepository: ISelectedCalendarRepository; - let mockFeaturesRepository: FeaturesRepository; + let mockFeatureRepository: IFeatureRepository; + let mockTeamFeatureRepository: ITeamFeatureRepository; + let mockUserFeatureRepository: IUserFeatureRepository & { + checkIfUserHasFeature: (userId: number, slug: string) => Promise; + }; let mockCalendarCacheEventService: CalendarCacheEventService; let mockCalendarSyncService: CalendarSyncService; let mockAdapter: { @@ -121,12 +126,14 @@ describe("CalendarSubscriptionService", () => { mockAdapterFactory = { get: vi.fn().mockReturnValue(mockAdapter), getProviders: vi.fn().mockReturnValue(["google_calendar", "office365_calendar"]), - getGenericCalendarSuffixes: vi.fn().mockReturnValue([ - "@group.v.calendar.google.com", - "@group.calendar.google.com", - "@import.calendar.google.com", - "@resource.calendar.google.com", - ]), + getGenericCalendarSuffixes: vi + .fn() + .mockReturnValue([ + "@group.v.calendar.google.com", + "@group.calendar.google.com", + "@import.calendar.google.com", + "@resource.calendar.google.com", + ]), }; mockSelectedCalendarRepository = { @@ -137,13 +144,23 @@ describe("CalendarSubscriptionService", () => { updateSubscription: vi.fn().mockResolvedValue(mockSelectedCalendar), }; - mockFeaturesRepository = { + mockFeatureRepository = { + findAll: vi.fn(), + findBySlug: vi.fn(), + update: vi.fn(), checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true), - checkIfUserHasFeature: vi.fn().mockResolvedValue(true), + }; + + mockTeamFeatureRepository = { checkIfTeamHasFeature: vi.fn().mockResolvedValue(true), getTeamsWithFeatureEnabled: vi.fn().mockResolvedValue([1, 2, 3]), }; + mockUserFeatureRepository = { + checkIfUserHasFeature: vi.fn().mockResolvedValue(true), + checkIfUserHasFeatureNonHierarchical: vi.fn().mockResolvedValue(true), + }; + mockCalendarCacheEventService = { handleEvents: vi.fn().mockResolvedValue(undefined), cleanupCache: vi.fn().mockResolvedValue(undefined), @@ -157,7 +174,9 @@ describe("CalendarSubscriptionService", () => { service = new CalendarSubscriptionService({ adapterFactory: mockAdapterFactory, selectedCalendarRepository: mockSelectedCalendarRepository, - featuresRepository: mockFeaturesRepository, + featureRepository: mockFeatureRepository, + teamFeatureRepository: mockTeamFeatureRepository, + userFeatureRepository: mockUserFeatureRepository, calendarCacheEventService: mockCalendarCacheEventService, calendarSyncService: mockCalendarSyncService, }); @@ -237,7 +256,7 @@ describe("CalendarSubscriptionService", () => { describe("unsubscribe", () => { test("should successfully unsubscribe from a calendar", async () => { - mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true); + mockFeatureRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true); await service.unsubscribe("test-calendar-id"); @@ -250,7 +269,7 @@ describe("CalendarSubscriptionService", () => { }); test("should not cleanup cache if cache is disabled", async () => { - mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(false); + mockFeatureRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(false); await service.unsubscribe("test-calendar-id"); @@ -307,10 +326,10 @@ describe("CalendarSubscriptionService", () => { describe("processEvents", () => { test("should process events when both cache and sync are enabled", async () => { - mockFeaturesRepository.checkIfFeatureIsEnabledGlobally + mockFeatureRepository.checkIfFeatureIsEnabledGlobally .mockResolvedValueOnce(true) .mockResolvedValueOnce(true); - mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(true); + mockUserFeatureRepository.checkIfUserHasFeature.mockResolvedValue(true); await service.processEvents(mockSelectedCalendar); @@ -332,10 +351,10 @@ describe("CalendarSubscriptionService", () => { }); test("should not process cache when cache is disabled globally", async () => { - mockFeaturesRepository.checkIfFeatureIsEnabledGlobally + mockFeatureRepository.checkIfFeatureIsEnabledGlobally .mockResolvedValueOnce(false) .mockResolvedValueOnce(true); - mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(true); + mockUserFeatureRepository.checkIfUserHasFeature.mockResolvedValue(true); await service.processEvents(mockSelectedCalendar); @@ -344,10 +363,10 @@ describe("CalendarSubscriptionService", () => { }); test("should not process cache when cache is disabled for user", async () => { - mockFeaturesRepository.checkIfFeatureIsEnabledGlobally + mockFeatureRepository.checkIfFeatureIsEnabledGlobally .mockResolvedValueOnce(true) .mockResolvedValueOnce(true); - mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(false); + mockUserFeatureRepository.checkIfUserHasFeature.mockResolvedValue(false); await service.processEvents(mockSelectedCalendar); @@ -356,7 +375,7 @@ describe("CalendarSubscriptionService", () => { }); test("should return early when both cache and sync are disabled", async () => { - mockFeaturesRepository.checkIfFeatureIsEnabledGlobally + mockFeatureRepository.checkIfFeatureIsEnabledGlobally .mockResolvedValueOnce(false) .mockResolvedValueOnce(false); @@ -368,10 +387,10 @@ describe("CalendarSubscriptionService", () => { }); test("should handle API errors and update sync status", async () => { - mockFeaturesRepository.checkIfFeatureIsEnabledGlobally + mockFeatureRepository.checkIfFeatureIsEnabledGlobally .mockResolvedValueOnce(true) .mockResolvedValueOnce(true); - mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(true); + mockUserFeatureRepository.checkIfUserHasFeature.mockResolvedValue(true); const apiError = new Error("API Error"); mockAdapter.fetchEvents.mockRejectedValue(apiError); @@ -387,10 +406,10 @@ describe("CalendarSubscriptionService", () => { }); test("should return early when no events are fetched", async () => { - mockFeaturesRepository.checkIfFeatureIsEnabledGlobally + mockFeatureRepository.checkIfFeatureIsEnabledGlobally .mockResolvedValueOnce(true) .mockResolvedValueOnce(true); - mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(true); + mockUserFeatureRepository.checkIfUserHasFeature.mockResolvedValue(true); mockAdapter.fetchEvents.mockResolvedValue({ ...mockEvents, @@ -422,10 +441,13 @@ describe("CalendarSubscriptionService", () => { await service.checkForNewSubscriptions(); + expect(mockTeamFeatureRepository.getTeamsWithFeatureEnabled).toHaveBeenCalledWith( + "calendar-subscription-cache" + ); expect(mockSelectedCalendarRepository.findNextSubscriptionBatch).toHaveBeenCalledWith({ take: 100, integrations: ["google_calendar", "office365_calendar"], - featureIds: ["calendar-subscription-cache"], + teamIds: [1, 2, 3], genericCalendarSuffixes: [ "@group.v.calendar.google.com", "@group.calendar.google.com", @@ -449,10 +471,13 @@ describe("CalendarSubscriptionService", () => { await service.checkForNewSubscriptions(); + expect(mockTeamFeatureRepository.getTeamsWithFeatureEnabled).toHaveBeenCalledWith( + "calendar-subscription-cache" + ); expect(mockSelectedCalendarRepository.findNextSubscriptionBatch).toHaveBeenCalledWith({ take: 100, integrations: ["google_calendar", "office365_calendar"], - featureIds: ["calendar-subscription-cache"], + teamIds: [1, 2, 3], genericCalendarSuffixes: [ "@group.v.calendar.google.com", "@group.calendar.google.com", @@ -465,6 +490,20 @@ describe("CalendarSubscriptionService", () => { expect(subscribeSpy).toHaveBeenCalledWith("calendar-with-cache-2"); }); + test("should skip when cache feature is globally disabled", async () => { + mockFeatureRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(false); + const subscribeSpy = vi.spyOn(service, "subscribe").mockResolvedValue(undefined); + + await service.checkForNewSubscriptions(); + + expect(mockFeatureRepository.checkIfFeatureIsEnabledGlobally).toHaveBeenCalledWith( + "calendar-subscription-cache" + ); + expect(mockTeamFeatureRepository.getTeamsWithFeatureEnabled).not.toHaveBeenCalled(); + expect(mockSelectedCalendarRepository.findNextSubscriptionBatch).not.toHaveBeenCalled(); + expect(subscribeSpy).not.toHaveBeenCalled(); + }); + test("should not process any calendars when no calendars are returned", async () => { mockSelectedCalendarRepository.findNextSubscriptionBatch.mockResolvedValue([]); @@ -472,10 +511,13 @@ describe("CalendarSubscriptionService", () => { await service.checkForNewSubscriptions(); + expect(mockTeamFeatureRepository.getTeamsWithFeatureEnabled).toHaveBeenCalledWith( + "calendar-subscription-cache" + ); expect(mockSelectedCalendarRepository.findNextSubscriptionBatch).toHaveBeenCalledWith({ take: 100, integrations: ["google_calendar", "office365_calendar"], - featureIds: ["calendar-subscription-cache"], + teamIds: [1, 2, 3], genericCalendarSuffixes: [ "@group.v.calendar.google.com", "@group.calendar.google.com", @@ -489,35 +531,35 @@ describe("CalendarSubscriptionService", () => { describe("feature flag methods", () => { test("isCacheEnabled should check global cache feature", async () => { - mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true); + mockFeatureRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true); const result = await service.isCacheEnabled(); expect(result).toBe(true); - expect(mockFeaturesRepository.checkIfFeatureIsEnabledGlobally).toHaveBeenCalledWith( + expect(mockFeatureRepository.checkIfFeatureIsEnabledGlobally).toHaveBeenCalledWith( "calendar-subscription-cache" ); }); test("isCacheEnabledForUser should check user cache feature", async () => { - mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(true); + mockUserFeatureRepository.checkIfUserHasFeature.mockResolvedValue(true); const result = await service.isCacheEnabledForUser(1); expect(result).toBe(true); - expect(mockFeaturesRepository.checkIfUserHasFeature).toHaveBeenCalledWith( + expect(mockUserFeatureRepository.checkIfUserHasFeature).toHaveBeenCalledWith( 1, "calendar-subscription-cache" ); }); test("isSyncEnabled should check global sync feature", async () => { - mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true); + mockFeatureRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true); const result = await service.isSyncEnabled(); expect(result).toBe(true); - expect(mockFeaturesRepository.checkIfFeatureIsEnabledGlobally).toHaveBeenCalledWith( + expect(mockFeatureRepository.checkIfFeatureIsEnabledGlobally).toHaveBeenCalledWith( "calendar-subscription-sync" ); }); diff --git a/packages/features/ee/teams/services/teamService.ts b/packages/features/ee/teams/services/teamService.ts index ccce15abd0..dd2c9dc2d3 100644 --- a/packages/features/ee/teams/services/teamService.ts +++ b/packages/features/ee/teams/services/teamService.ts @@ -1,5 +1,4 @@ import { randomBytes } from "node:crypto"; - import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing"; import { SeatChangeTrackingService } from "@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService"; import { deleteWorkfowRemindersOfRemovedMember } from "@calcom/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember"; @@ -11,9 +10,9 @@ import { createAProfileForAnExistingUser } from "@calcom/features/profile/lib/cr import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { deleteDomain } from "@calcom/lib/domainManager/organization"; -import logger from "@calcom/lib/logger"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { ErrorWithCode } from "@calcom/lib/errors"; +import logger from "@calcom/lib/logger"; import { prisma } from "@calcom/prisma"; import type { Membership } from "@calcom/prisma/client"; import { Prisma } from "@calcom/prisma/client"; @@ -107,7 +106,7 @@ export class TeamService { if (!isOrgContext) { return teamInviteLink; } - const gettingStartedPath = await OnboardingPathService.getGettingStartedPathWhenInvited(prisma); + const gettingStartedPath = await OnboardingPathService.getGettingStartedPathWhenInvited(); const orgInviteLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=${gettingStartedPath}`; return orgInviteLink; } @@ -607,4 +606,4 @@ export class TeamService { }), ]); } -} \ No newline at end of file +} diff --git a/packages/features/flags/repositories/CachedTeamFeatureRepository.ts b/packages/features/flags/repositories/CachedTeamFeatureRepository.ts index 80e3cb3ec9..3c27d3cd61 100644 --- a/packages/features/flags/repositories/CachedTeamFeatureRepository.ts +++ b/packages/features/flags/repositories/CachedTeamFeatureRepository.ts @@ -1,9 +1,7 @@ -import { z } from "zod"; - import { Memoize, Unmemoize } from "@calcom/features/cache"; import type { TeamFeaturesDto } from "@calcom/lib/dto/TeamFeaturesDto"; import { TeamFeaturesDtoSchema } from "@calcom/lib/dto/TeamFeaturesDto"; - +import { z } from "zod"; import type { FeatureId, TeamFeatures } from "../config"; import type { ITeamFeatureRepository } from "./PrismaTeamFeatureRepository"; import { booleanSchema } from "./schemas"; @@ -91,4 +89,8 @@ export class CachedTeamFeatureRepository implements ITeamFeatureRepository { async getEnabledFeatures(teamId: number): Promise { return this.prismaTeamFeatureRepository.getEnabledFeatures(teamId); } + + async getTeamsWithFeatureEnabled(featureId: FeatureId): Promise { + return this.prismaTeamFeatureRepository.getTeamsWithFeatureEnabled(featureId); + } } diff --git a/packages/features/flags/repositories/PrismaFeatureRepository.ts b/packages/features/flags/repositories/PrismaFeatureRepository.ts index 62083f7b6b..c05de15b6b 100644 --- a/packages/features/flags/repositories/PrismaFeatureRepository.ts +++ b/packages/features/flags/repositories/PrismaFeatureRepository.ts @@ -6,6 +6,7 @@ export interface IFeatureRepository { findAll(): Promise; findBySlug(slug: string): Promise; update(input: { featureId: FeatureId; enabled: boolean; updatedBy?: number }): Promise; + checkIfFeatureIsEnabledGlobally(slug: string): Promise; } export class PrismaFeatureRepository implements IFeatureRepository { @@ -67,4 +68,12 @@ export class PrismaFeatureRepository implements IFeatureRepository { }, }); } + + async checkIfFeatureIsEnabledGlobally(slug: string): Promise { + const feature = await this.prisma.feature.findUnique({ + where: { slug }, + select: { enabled: true }, + }); + return Boolean(feature?.enabled); + } } diff --git a/packages/features/flags/repositories/PrismaTeamFeatureRepository.ts b/packages/features/flags/repositories/PrismaTeamFeatureRepository.ts index a12d5933b6..ef05123c1f 100644 --- a/packages/features/flags/repositories/PrismaTeamFeatureRepository.ts +++ b/packages/features/flags/repositories/PrismaTeamFeatureRepository.ts @@ -1,9 +1,7 @@ -import { captureException } from "@sentry/nextjs"; - import type { TeamFeaturesDto } from "@calcom/lib/dto/TeamFeaturesDto"; import type { PrismaClient } from "@calcom/prisma/client"; import { Prisma } from "@calcom/prisma/client"; - +import { captureException } from "@sentry/nextjs"; import type { FeatureId, TeamFeatures } from "../config"; export interface ITeamFeatureRepository { @@ -24,6 +22,7 @@ export interface ITeamFeatureRepository { setAutoOptIn(teamId: number, enabled: boolean): Promise; checkIfTeamHasFeature(teamId: number, featureId: FeatureId): Promise; getEnabledFeatures(teamId: number): Promise; + getTeamsWithFeatureEnabled(featureId: FeatureId): Promise; } export class PrismaTeamFeatureRepository implements ITeamFeatureRepository { @@ -250,4 +249,18 @@ export class PrismaTeamFeatureRepository implements ITeamFeatureRepository { return features; } + + async getTeamsWithFeatureEnabled(featureId: FeatureId): Promise { + const result = await this.prisma.teamFeatures.findMany({ + where: { + featureId, + enabled: true, + }, + select: { + teamId: true, + }, + }); + + return result.map((tf) => tf.teamId); + } } diff --git a/packages/features/onboarding/lib/onboarding-path.service.ts b/packages/features/onboarding/lib/onboarding-path.service.ts index fb77777547..99dd3af440 100644 --- a/packages/features/onboarding/lib/onboarding-path.service.ts +++ b/packages/features/onboarding/lib/onboarding-path.service.ts @@ -1,24 +1,20 @@ -import { FeaturesRepository } from "@calcom/features/flags/features.repository"; -import type { PrismaClient } from "@calcom/prisma"; +import { getFeatureRepository } from "@calcom/features/di/containers/FeatureRepository"; export class OnboardingPathService { - static async getGettingStartedPath(prisma: PrismaClient): Promise { - const featuresRepository = new FeaturesRepository(prisma); - const onboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3"); + static async getGettingStartedPath(): Promise { + const featureRepository = getFeatureRepository(); + const onboardingV3Enabled = await featureRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3"); return onboardingV3Enabled ? "/onboarding/getting-started" : "/getting-started"; } - static async getGettingStartedPathWhenInvited(prisma: PrismaClient): Promise { - const featuresRepository = new FeaturesRepository(prisma); - const onboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3"); + static async getGettingStartedPathWhenInvited(): Promise { + const featureRepository = getFeatureRepository(); + const onboardingV3Enabled = await featureRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3"); return onboardingV3Enabled ? "/onboarding/personal/settings" : "/getting-started"; } - static async getGettingStartedPathWithParams( - prisma: PrismaClient, - queryParams?: Record - ): Promise { - const basePath = await OnboardingPathService.getGettingStartedPath(prisma); + static async getGettingStartedPathWithParams(queryParams?: Record): Promise { + const basePath = await OnboardingPathService.getGettingStartedPath(); if (!queryParams || Object.keys(queryParams).length === 0) { return basePath; diff --git a/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.interface.ts b/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.interface.ts index 9defbfae5c..176a86d74f 100644 --- a/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.interface.ts +++ b/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.interface.ts @@ -20,21 +20,21 @@ export interface ISelectedCalendarRepository { * Will check if syncSubscribedAt is null or channelExpiration is greater than current date * Calendars with recent subscription errors (last 24h) are skipped * Calendars with 3+ subscription errors are skipped entirely - * Joins with TeamFeatures to filter users belonging to teams with any of the specified features enabled + * Joins with Membership to filter users belonging to teams with the specified team IDs * * @param take the number of calendars to take - * @param featureIds the feature IDs to filter teams by (users in teams with any of these features enabled) + * @param teamIds the team IDs to filter by (users in these teams will be included) * @param integrations the list of integrations * @param genericCalendarSuffixes the list of generic calendar suffixes to exclude */ findNextSubscriptionBatch({ take, - featureIds, + teamIds, integrations, genericCalendarSuffixes, }: { take: number; - featureIds: string[]; + teamIds: number[]; integrations: string[]; genericCalendarSuffixes?: string[]; }): Promise; diff --git a/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.test.ts b/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.test.ts index 9bd49dc63d..67d6e5f790 100644 --- a/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.test.ts +++ b/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.test.ts @@ -1,8 +1,7 @@ -import { describe, test, expect, vi, beforeEach } from "vitest"; - import { SelectedCalendarRepository } from "@calcom/features/selectedCalendar/repositories/SelectedCalendarRepository"; import type { PrismaClient } from "@calcom/prisma"; import type { Prisma, SelectedCalendar } from "@calcom/prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; const mockPrismaClient = { selectedCalendar: { @@ -117,7 +116,7 @@ describe("SelectedCalendarRepository", () => { const result = await repository.findNextSubscriptionBatch({ take: 10, - featureIds: ["calendar-subscription-cache"], + teamIds: [1, 2, 3], integrations: ["google_calendar", "office365_calendar"], }); @@ -128,14 +127,7 @@ describe("SelectedCalendarRepository", () => { teams: { some: { accepted: true, - team: { - features: { - some: { - featureId: { in: ["calendar-subscription-cache"] }, - enabled: true, - }, - }, - }, + teamId: { in: [1, 2, 3] }, }, }, }, @@ -148,10 +140,7 @@ describe("SelectedCalendarRepository", () => { ], }, { - OR: [ - { syncSubscribedErrorAt: null }, - { syncSubscribedErrorAt: { lt: expect.any(Date) } }, - ], + OR: [{ syncSubscribedErrorAt: null }, { syncSubscribedErrorAt: { lt: expect.any(Date) } }], }, { syncSubscribedErrorCount: { lt: 3 }, @@ -170,7 +159,7 @@ describe("SelectedCalendarRepository", () => { const result = await repository.findNextSubscriptionBatch({ take: 5, - featureIds: ["calendar-subscription-cache"], + teamIds: [1, 2, 3], integrations: [], }); @@ -181,14 +170,7 @@ describe("SelectedCalendarRepository", () => { teams: { some: { accepted: true, - team: { - features: { - some: { - featureId: { in: ["calendar-subscription-cache"] }, - enabled: true, - }, - }, - }, + teamId: { in: [1, 2, 3] }, }, }, }, @@ -201,10 +183,7 @@ describe("SelectedCalendarRepository", () => { ], }, { - OR: [ - { syncSubscribedErrorAt: null }, - { syncSubscribedErrorAt: { lt: expect.any(Date) } }, - ], + OR: [{ syncSubscribedErrorAt: null }, { syncSubscribedErrorAt: { lt: expect.any(Date) } }], }, { syncSubscribedErrorCount: { lt: 3 }, @@ -225,7 +204,7 @@ describe("SelectedCalendarRepository", () => { const result = await repository.findNextSubscriptionBatch({ take: 10, - featureIds: ["calendar-subscription-cache"], + teamIds: [1, 2, 3], integrations: ["google_calendar"], genericCalendarSuffixes: genericSuffixes, }); @@ -237,14 +216,7 @@ describe("SelectedCalendarRepository", () => { teams: { some: { accepted: true, - team: { - features: { - some: { - featureId: { in: ["calendar-subscription-cache"] }, - enabled: true, - }, - }, - }, + teamId: { in: [1, 2, 3] }, }, }, }, @@ -257,10 +229,7 @@ describe("SelectedCalendarRepository", () => { ], }, { - OR: [ - { syncSubscribedErrorAt: null }, - { syncSubscribedErrorAt: { lt: expect.any(Date) } }, - ], + OR: [{ syncSubscribedErrorAt: null }, { syncSubscribedErrorAt: { lt: expect.any(Date) } }], }, { syncSubscribedErrorCount: { lt: 3 }, diff --git a/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.ts b/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.ts index 057463bff6..c8a36a30c4 100644 --- a/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.ts +++ b/packages/features/selectedCalendar/repositories/SelectedCalendarRepository.ts @@ -19,12 +19,12 @@ export class SelectedCalendarRepository implements ISelectedCalendarRepository { async findNextSubscriptionBatch({ take, - featureIds, + teamIds, integrations, genericCalendarSuffixes, }: { take: number; - featureIds: string[]; + teamIds: number[]; integrations: string[]; genericCalendarSuffixes?: string[]; }) { @@ -32,11 +32,7 @@ export class SelectedCalendarRepository implements ISelectedCalendarRepository { const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); const needsSubscriptionFilter: Prisma.SelectedCalendarWhereInput = { - OR: [ - { syncSubscribedAt: null }, - { channelExpiration: null }, - { channelExpiration: { lte: now } }, - ], + OR: [{ syncSubscribedAt: null }, { channelExpiration: null }, { channelExpiration: { lte: now } }], }; const retryableWindowFilter: Prisma.SelectedCalendarWhereInput = { @@ -66,14 +62,7 @@ export class SelectedCalendarRepository implements ISelectedCalendarRepository { teams: { some: { accepted: true, - team: { - features: { - some: { - featureId: { in: featureIds }, - enabled: true, - }, - }, - }, + teamId: { in: teamIds }, }, }, }, diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts index be8486256c..8ddd5b497e 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts @@ -24,7 +24,6 @@ import { MembershipRole } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; import type { TFunction } from "i18next"; - import { isEmail } from "../util"; import type { TeamWithParent } from "./types"; @@ -541,7 +540,7 @@ export async function sendSignupToOrganizationEmail({ }) { try { const verificationToken = await createVerificationToken(usernameOrEmail, teamId); - const gettingStartedPath = await OnboardingPathService.getGettingStartedPathWhenInvited(prisma); + const gettingStartedPath = await OnboardingPathService.getGettingStartedPathWhenInvited(); await sendTeamInviteEmail({ language: translation, from: inviterName || `${team.name}'s admin`, @@ -758,7 +757,7 @@ export const sendExistingUserTeamInviteEmails = async ({ if (!user.completedOnboarding && !user.password?.hash && user.identityProvider === "CAL") { const verificationToken = await createVerificationToken(user.email, teamId); - const gettingStartedPath = await OnboardingPathService.getGettingStartedPathWhenInvited(prisma); + const gettingStartedPath = await OnboardingPathService.getGettingStartedPathWhenInvited(); inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${verificationToken.token}&callbackUrl=${gettingStartedPath}`; inviteTeamOptions.isCalcomMember = false; } else if (!isAutoJoin) { diff --git a/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts b/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts index 96e85aac5c..4be85a3c58 100644 --- a/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts @@ -1,12 +1,11 @@ import { sendTeamInviteEmail } from "@calcom/emails/organization-email-service"; -import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service"; +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { getTranslation } from "@calcom/lib/server/i18n"; import { VerificationTokenRepository } from "@calcom/lib/server/repository/verificationToken"; import { prisma } from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; - import { ensureAtleastAdminPermissions, getTeamOrThrow } from "./inviteMember/utils"; import type { TResendInvitationInputSchema } from "./resendInvitation.schema"; @@ -27,7 +26,9 @@ export const resendInvitationHandler = async ({ ctx, input }: InviteMemberOption isOrg: input.isOrg, }); - let verificationToken: Awaited> | undefined; + let verificationToken: + | Awaited> + | undefined; try { verificationToken = await VerificationTokenRepository.updateTeamInviteTokenExpirationDate({ @@ -52,7 +53,7 @@ export const resendInvitationHandler = async ({ ctx, input }: InviteMemberOption if (user?.completedOnboarding) { inviteTeamOptions.joinLink = `${WEBAPP_URL}/teams?token=${verificationToken.token}&autoAccept=true`; } else { - const gettingStartedPath = await OnboardingPathService.getGettingStartedPathWhenInvited(prisma); + const gettingStartedPath = await OnboardingPathService.getGettingStartedPathWhenInvited(); inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${verificationToken.token}&callbackUrl=${gettingStartedPath}`; inviteTeamOptions.isCalcomMember = false; }