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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * remove redundant comment --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
cb36fc201f
commit
512002aaef
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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<Params>
|
||||
const calendarSubscriptionService = new CalendarSubscriptionService({
|
||||
adapterFactory: new DefaultAdapterFactory(),
|
||||
selectedCalendarRepository: new SelectedCalendarRepository(prisma),
|
||||
featuresRepository: new FeaturesRepository(prisma),
|
||||
featureRepository: getFeatureRepository(),
|
||||
teamFeatureRepository: getTeamFeatureRepository(),
|
||||
userFeatureRepository: getUserFeatureRepository(),
|
||||
calendarSyncService,
|
||||
calendarCacheEventService,
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<boolean>;
|
||||
};
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
return this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||
return this.deps.featureRepository.checkIfFeatureIsEnabledGlobally(
|
||||
CalendarSubscriptionService.CALENDAR_SUBSCRIPTION_SYNC_FEATURE
|
||||
);
|
||||
}
|
||||
|
||||
+77
-35
@@ -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<boolean>;
|
||||
};
|
||||
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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TeamFeatures | null> {
|
||||
return this.prismaTeamFeatureRepository.getEnabledFeatures(teamId);
|
||||
}
|
||||
|
||||
async getTeamsWithFeatureEnabled(featureId: FeatureId): Promise<number[]> {
|
||||
return this.prismaTeamFeatureRepository.getTeamsWithFeatureEnabled(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface IFeatureRepository {
|
||||
findAll(): Promise<FeatureDto[]>;
|
||||
findBySlug(slug: string): Promise<FeatureDto | null>;
|
||||
update(input: { featureId: FeatureId; enabled: boolean; updatedBy?: number }): Promise<FeatureDto>;
|
||||
checkIfFeatureIsEnabledGlobally(slug: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export class PrismaFeatureRepository implements IFeatureRepository {
|
||||
@@ -67,4 +68,12 @@ export class PrismaFeatureRepository implements IFeatureRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async checkIfFeatureIsEnabledGlobally(slug: string): Promise<boolean> {
|
||||
const feature = await this.prisma.feature.findUnique({
|
||||
where: { slug },
|
||||
select: { enabled: true },
|
||||
});
|
||||
return Boolean(feature?.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
checkIfTeamHasFeature(teamId: number, featureId: FeatureId): Promise<boolean>;
|
||||
getEnabledFeatures(teamId: number): Promise<TeamFeatures | null>;
|
||||
getTeamsWithFeatureEnabled(featureId: FeatureId): Promise<number[]>;
|
||||
}
|
||||
|
||||
export class PrismaTeamFeatureRepository implements ITeamFeatureRepository {
|
||||
@@ -250,4 +249,18 @@ export class PrismaTeamFeatureRepository implements ITeamFeatureRepository {
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
async getTeamsWithFeatureEnabled(featureId: FeatureId): Promise<number[]> {
|
||||
const result = await this.prisma.teamFeatures.findMany({
|
||||
where: {
|
||||
featureId,
|
||||
enabled: true,
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result.map((tf) => tf.teamId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const onboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3");
|
||||
static async getGettingStartedPath(): Promise<string> {
|
||||
const featureRepository = getFeatureRepository();
|
||||
const onboardingV3Enabled = await featureRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3");
|
||||
return onboardingV3Enabled ? "/onboarding/getting-started" : "/getting-started";
|
||||
}
|
||||
|
||||
static async getGettingStartedPathWhenInvited(prisma: PrismaClient): Promise<string> {
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const onboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3");
|
||||
static async getGettingStartedPathWhenInvited(): Promise<string> {
|
||||
const featureRepository = getFeatureRepository();
|
||||
const onboardingV3Enabled = await featureRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3");
|
||||
return onboardingV3Enabled ? "/onboarding/personal/settings" : "/getting-started";
|
||||
}
|
||||
|
||||
static async getGettingStartedPathWithParams(
|
||||
prisma: PrismaClient,
|
||||
queryParams?: Record<string, string>
|
||||
): Promise<string> {
|
||||
const basePath = await OnboardingPathService.getGettingStartedPath(prisma);
|
||||
static async getGettingStartedPathWithParams(queryParams?: Record<string, string>): Promise<string> {
|
||||
const basePath = await OnboardingPathService.getGettingStartedPath();
|
||||
|
||||
if (!queryParams || Object.keys(queryParams).length === 0) {
|
||||
return basePath;
|
||||
|
||||
+4
-4
@@ -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<SelectedCalendar[]>;
|
||||
|
||||
+10
-41
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<ReturnType<typeof VerificationTokenRepository.updateTeamInviteTokenExpirationDate>> | undefined;
|
||||
let verificationToken:
|
||||
| Awaited<ReturnType<typeof VerificationTokenRepository.updateTeamInviteTokenExpirationDate>>
|
||||
| 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user