Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { BookingAuditTaskConsumer } from "@calcom/features/booking-audit/lib/ser
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
import { moduleLoader as bookingAuditRepositoryModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditRepository.module";
import { moduleLoader as auditActorRepositoryModuleLoader } from "@calcom/features/booking-audit/di/AuditActorRepository.module";
import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/Features";
import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/FeaturesRepository";
import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User";

import { createModule, bindModuleToClassOnToken } from "../../di/di";
Expand All @@ -28,4 +28,3 @@ export const moduleLoader = {
token,
loadModule
};

Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { RegularBookingService } from "@calcom/features/bookings/lib/service/Reg
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { moduleLoader as bookingRepositoryModuleLoader } from "@calcom/features/di/modules/Booking";
import { moduleLoader as checkBookingAndDurationLimitsModuleLoader } from "@calcom/features/di/modules/CheckBookingAndDurationLimits";
import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/Features";
import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/FeaturesRepository";
import { moduleLoader as luckyUserServiceModuleLoader } from "@calcom/features/di/modules/LuckyUser";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { moduleLoader as userRepositoryModuleLoader } from "@calcom/features/di/modules/User";
Expand Down
2 changes: 1 addition & 1 deletion packages/features/di/containers/AvailableSlots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { bookingRepositoryModule } from "../modules/Booking";
import { busyTimesModule } from "../modules/BusyTimes";
import { checkBookingLimitsModule } from "../modules/CheckBookingLimits";
import { eventTypeRepositoryModule } from "../modules/EventType";
import { featuresRepositoryModule } from "../modules/Features";
import { featuresRepositoryModule } from "../modules/FeaturesRepository";
import { filterHostsModule } from "../modules/FilterHosts";
import { getUserAvailabilityModule } from "../modules/GetUserAvailability";
import { holidayRepositoryModule } from "../modules/Holiday";
Expand Down
11 changes: 11 additions & 0 deletions packages/features/di/containers/FeatureOptInService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { IFeatureOptInService } from "@calcom/features/feature-opt-in/services/IFeatureOptInService";

import { createContainer } from "../di";
import { moduleLoader as featureOptInServiceModuleLoader } from "../modules/FeatureOptInService";

const featureOptInServiceContainer = createContainer();

export function getFeatureOptInService(): IFeatureOptInService {
featureOptInServiceModuleLoader.loadModule(featureOptInServiceContainer);
return featureOptInServiceContainer.get<IFeatureOptInService>(featureOptInServiceModuleLoader.token);
}
9 changes: 9 additions & 0 deletions packages/features/di/containers/FeaturesRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createContainer } from "../di";
import { type FeaturesRepository, moduleLoader as featuresRepositoryModuleLoader } from "../modules/FeaturesRepository";

const featuresRepositoryContainer = createContainer();

export function getFeaturesRepository(): FeaturesRepository {
featuresRepositoryModuleLoader.loadModule(featuresRepositoryContainer);
return featuresRepositoryContainer.get<FeaturesRepository>(featuresRepositoryModuleLoader.token);
}
24 changes: 24 additions & 0 deletions packages/features/di/modules/FeatureOptInService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { FEATURE_OPT_IN_DI_TOKENS } from "@calcom/features/feature-opt-in/di/tokens";
import { FeatureOptInService } from "@calcom/features/feature-opt-in/services/FeatureOptInService";

import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "../di";
import { moduleLoader as featuresRepositoryModuleLoader } from "./FeaturesRepository";

const thisModule = createModule();
const token = FEATURE_OPT_IN_DI_TOKENS.FEATURE_OPT_IN_SERVICE;
const moduleToken = FEATURE_OPT_IN_DI_TOKENS.FEATURE_OPT_IN_SERVICE_MODULE;

const loadModule = bindModuleToClassOnToken({
module: thisModule,
moduleToken,
token,
classs: FeatureOptInService,
dep: featuresRepositoryModuleLoader,
});

export const moduleLoader: ModuleLoader = {
token,
loadModule,
};

export type { FeatureOptInService };
16 changes: 0 additions & 16 deletions packages/features/di/modules/Features.ts

This file was deleted.

24 changes: 24 additions & 0 deletions packages/features/di/modules/FeaturesRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { FLAGS_DI_TOKENS } from "@calcom/features/flags/di/tokens";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";

import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "../di";
import { moduleLoader as prismaModuleLoader } from "./Prisma";

export const featuresRepositoryModule = createModule();
const token = FLAGS_DI_TOKENS.FEATURES_REPOSITORY;
const moduleToken = FLAGS_DI_TOKENS.FEATURES_REPOSITORY_MODULE;

const loadModule = bindModuleToClassOnToken({
module: featuresRepositoryModule,
moduleToken,
token,
classs: FeaturesRepository,
dep: prismaModuleLoader,
});

export const moduleLoader: ModuleLoader = {
token,
loadModule,
};

export type { FeaturesRepository };
6 changes: 4 additions & 2 deletions packages/features/di/tokens.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { BOOKING_DI_TOKENS } from "@calcom/features/bookings/di/tokens";
import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens";
import { FEATURE_OPT_IN_DI_TOKENS } from "@calcom/features/feature-opt-in/di/tokens";
import { FLAGS_DI_TOKENS } from "@calcom/features/flags/di/tokens";
import { HASHED_LINK_DI_TOKENS } from "@calcom/features/hashedLink/di/tokens";
import { OAUTH_DI_TOKENS } from "@calcom/features/oauth/di/tokens";
import { ORGANIZATION_DI_TOKENS } from "@calcom/features/ee/organizations/di/tokens";
Expand Down Expand Up @@ -34,8 +36,8 @@ export const DI_TOKENS = {
INSIGHTS_ROUTING_SERVICE_MODULE: Symbol("InsightsRoutingServiceModule"),
INSIGHTS_BOOKING_SERVICE: Symbol("InsightsBookingService"),
INSIGHTS_BOOKING_SERVICE_MODULE: Symbol("InsightsBookingServiceModule"),
FEATURES_REPOSITORY: Symbol("FeaturesRepository"),
FEATURES_REPOSITORY_MODULE: Symbol("FeaturesRepositoryModule"),
...FLAGS_DI_TOKENS,
...FEATURE_OPT_IN_DI_TOKENS,
CHECK_BOOKING_LIMITS_SERVICE: Symbol("CheckBookingLimitsService"),
CHECK_BOOKING_LIMITS_SERVICE_MODULE: Symbol("CheckBookingLimitsServiceModule"),
CHECK_BOOKING_AND_DURATION_LIMITS_SERVICE: Symbol("CheckBookingAndDurationLimitsService"),
Expand Down
4 changes: 4 additions & 0 deletions packages/features/feature-opt-in/di/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const FEATURE_OPT_IN_DI_TOKENS = {
FEATURE_OPT_IN_SERVICE: Symbol("FeatureOptInService"),
FEATURE_OPT_IN_SERVICE_MODULE: Symbol("FeatureOptInServiceModule"),
};
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { afterEach, describe, expect, it } from "vitest";

import { getFeatureOptInService } from "@calcom/features/di/containers/FeatureOptInService";
import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository";
import type { FeatureId } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { prisma } from "@calcom/prisma";

import { FeatureOptInService } from "./FeatureOptInService";
import type { IFeatureOptInService } from "./IFeatureOptInService";

// Helper to generate unique identifiers per test
const uniqueId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
Expand All @@ -30,7 +32,7 @@ interface TestEntities {
team: { id: number };
team2: { id: number };
featuresRepository: FeaturesRepository;
service: FeatureOptInService;
service: IFeatureOptInService;
createdFeatures: string[];
setupFeature: (enabled?: boolean) => Promise<FeatureId>;
}
Expand Down Expand Up @@ -77,8 +79,8 @@ async function setup(): Promise<TestEntities> {
},
});

const featuresRepository = new FeaturesRepository(prisma);
const service = new FeatureOptInService(featuresRepository);
const featuresRepository = getFeaturesRepository();
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Potential test isolation issue: getFeaturesRepository() and getFeatureOptInService() use separate DI containers, creating different FeaturesRepository instances. The clearFeaturesCache(featuresRepository) call only clears the cache on the standalone repository instance, not the one used internally by the service. This could cause test flakiness if the service's internal repository has cached stale data.

Consider either:

  1. Using a shared DI container for both
  2. Exposing a method to get the repository instance from the service for cache clearing
  3. Creating a helper that clears both caches
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/features/feature-opt-in/services/FeatureOptInService.integration-test.ts, line 82:

<comment>Potential test isolation issue: `getFeaturesRepository()` and `getFeatureOptInService()` use separate DI containers, creating different `FeaturesRepository` instances. The `clearFeaturesCache(featuresRepository)` call only clears the cache on the standalone repository instance, not the one used internally by the service. This could cause test flakiness if the service's internal repository has cached stale data.

Consider either:
1. Using a shared DI container for both
2. Exposing a method to get the repository instance from the service for cache clearing
3. Creating a helper that clears both caches</comment>

<file context>
@@ -77,8 +79,8 @@ async function setup(): Promise<TestEntities> {
 
-  const featuresRepository = new FeaturesRepository(prisma);
-  const service = new FeatureOptInService(featuresRepository);
+  const featuresRepository = getFeaturesRepository();
+  const service = getFeatureOptInService();
 
</file context>
Fix with Cubic

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review! However, I believe this concern is not actually valid for this specific case.

The FeaturesRepository cache is a static property (class-level, not instance-level):

private static featuresCache: { data: any[]; expiry: number } | null = null;

And clearCache() clears this static property:

private clearCache() {
  FeaturesRepository.featuresCache = null;
}

Since the cache is static, calling clearFeaturesCache(featuresRepository) clears the cache for all instances of FeaturesRepository, including the one inside the service. The tests work correctly as-is because the cache is shared at the class level, not the instance level.

Additionally, there's a follow-up PR coming that will replace this static cache with a proper Redis cache, which will make this even cleaner.

const service = getFeatureOptInService();

// Helper to create a feature for a test and track it for cleanup
const setupFeature = async (enabled = true): Promise<FeatureId> => {
Expand Down
16 changes: 2 additions & 14 deletions packages/features/feature-opt-in/services/FeatureOptInService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,13 @@ import type { FeaturesRepository } from "@calcom/features/flags/features.reposit
import { OPT_IN_FEATURES } from "../config";
import { applyAutoOptIn } from "../lib/applyAutoOptIn";
import { computeEffectiveStateAcrossTeams } from "../lib/computeEffectiveState";

type ResolvedFeatureState = {
featureId: FeatureId;
globalEnabled: boolean;
orgState: FeatureState; // Raw state (before auto-opt-in transform)
teamStates: FeatureState[]; // Raw states
userState: FeatureState | undefined; // Raw state
effectiveEnabled: boolean;
// Auto-opt-in flags for UI to show checkbox state
orgAutoOptIn: boolean;
teamAutoOptIns: boolean[];
userAutoOptIn: boolean;
};
import type { IFeatureOptInService, ResolvedFeatureState } from "./IFeatureOptInService";

/**
* Service class for managing feature opt-in logic.
* Computes effective states based on global, org, team, and user settings.
*/
export class FeatureOptInService {
export class FeatureOptInService implements IFeatureOptInService {
constructor(private featuresRepository: FeaturesRepository) {}

/**
Expand Down
39 changes: 39 additions & 0 deletions packages/features/feature-opt-in/services/IFeatureOptInService.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think the convention is to name interface files starting with I

Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { FeatureId, FeatureState } from "@calcom/features/flags/config";

export type ResolvedFeatureState = {
featureId: FeatureId;
globalEnabled: boolean;
orgState: FeatureState; // Raw state (before auto-opt-in transform)
teamStates: FeatureState[]; // Raw states
userState: FeatureState | undefined; // Raw state
effectiveEnabled: boolean;
// Auto-opt-in flags for UI to show checkbox state
orgAutoOptIn: boolean;
teamAutoOptIns: boolean[];
userAutoOptIn: boolean;
};

export interface IFeatureOptInService {
resolveFeatureStatesAcrossTeams(input: {
userId: number;
orgId: number | null;
teamIds: number[];
featureIds: FeatureId[];
}): Promise<Record<string, ResolvedFeatureState>>;
listFeaturesForUser(input: { userId: number; orgId: number | null; teamIds: number[] }): Promise<
ResolvedFeatureState[]
>;
listFeaturesForTeam(
input: { teamId: number }
): Promise<{ featureId: FeatureId; globalEnabled: boolean; teamState: FeatureState }[]>;
setUserFeatureState(
input:
| { userId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number }
| { userId: number; featureId: FeatureId; state: "inherit" }
): Promise<void>;
setTeamFeatureState(
input:
| { teamId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number }
| { teamId: number; featureId: FeatureId; state: "inherit" }
): Promise<void>;
}
4 changes: 4 additions & 0 deletions packages/features/flags/di/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const FLAGS_DI_TOKENS = {
FEATURES_REPOSITORY: Symbol("FeaturesRepository"),
FEATURES_REPOSITORY_MODULE: Symbol("FeaturesRepositoryModule"),
};
7 changes: 2 additions & 5 deletions packages/trpc/server/routers/viewer/featureOptIn/_router.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { z } from "zod";

import { getFeatureOptInService } from "@calcom/features/di/containers/FeatureOptInService";
import { isOptInFeature } from "@calcom/features/feature-opt-in/config";
import { FeatureOptInService } from "@calcom/features/feature-opt-in/services/FeatureOptInService";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";

import { TRPCError } from "@trpc/server";
Expand All @@ -15,8 +13,7 @@ import { router } from "../../../trpc";

const featureStateSchema = z.enum(["enabled", "disabled", "inherit"]);

const featuresRepository = new FeaturesRepository(prisma);
const featureOptInService = new FeatureOptInService(featuresRepository);
const featureOptInService = getFeatureOptInService();

/**
* Helper to get user's org and team IDs from their memberships.
Expand Down
Loading