diff --git a/packages/features/cache/decorators/Memoize.ts b/packages/features/cache/decorators/Memoize.ts new file mode 100644 index 0000000000..547f2d089f --- /dev/null +++ b/packages/features/cache/decorators/Memoize.ts @@ -0,0 +1,72 @@ +import { getRedisService } from "@calcom/features/di/containers/Redis"; +import logger from "@calcom/lib/logger"; +import type { ZodSchema } from "zod"; +import { DEFAULT_TTL_MS } from "./types"; + +const log = logger.getSubLogger({ prefix: ["@Memoize"] }); + +interface MemoizeOptions { + // biome-ignore lint/complexity/noBannedTypes: Decorator key function needs to accept any argument types + key: Function; + ttl?: number; + schema?: ZodSchema; +} + +export function Memoize(config: MemoizeOptions) { + return ( + _target: object, + _propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor + ): TypedPropertyDescriptor => { + const originalMethod = descriptor.value as ((...args: unknown[]) => Promise) | undefined; + + if (!originalMethod || typeof originalMethod !== "function") { + throw new Error(`@Memoize can only be applied to methods`); + } + + const wrappedMethod = async function (this: unknown, ...args: unknown[]): Promise { + const cacheKey = config.key(...args) as string; + + // Try to get from cache, but don't let Redis failures break the flow + try { + const redis = getRedisService(); + const cached = await redis.get(cacheKey); + + if (cached !== null) { + if (config.schema) { + const parsed = config.schema.safeParse(cached); + if (parsed.success) { + return parsed.data; + } + } else { + return cached; + } + } + } catch (error) { + log.warn("Cache read failed, proceeding to fetch from source", { + message: error instanceof Error ? error.message : "Unknown error", + }); + } + + const result = await originalMethod.apply(this, args); + + // Try to cache the result, but don't let Redis failures break the flow + if (result !== null && result !== undefined) { + try { + const redis = getRedisService(); + await redis.set(cacheKey, result, { ttl: config.ttl ?? DEFAULT_TTL_MS }); + } catch (error) { + log.warn("Cache write failed", { + message: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + return result; + }; + + descriptor.value = wrappedMethod as T; + + return descriptor; + }; +} diff --git a/packages/features/cache/decorators/Unmemoize.ts b/packages/features/cache/decorators/Unmemoize.ts new file mode 100644 index 0000000000..134028f6d3 --- /dev/null +++ b/packages/features/cache/decorators/Unmemoize.ts @@ -0,0 +1,43 @@ +import { getRedisService } from "@calcom/features/di/containers/Redis"; +import logger from "@calcom/lib/logger"; + +const log = logger.getSubLogger({ prefix: ["@Unmemoize"] }); + +interface UnmemoizeOptions { + // biome-ignore lint/complexity/noBannedTypes: Decorator keys function needs to accept any argument types + keys: Function; +} + +export function Unmemoize(config: UnmemoizeOptions) { + return ( + _target: object, + _propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor + ): TypedPropertyDescriptor => { + const originalMethod = descriptor.value as ((...args: unknown[]) => Promise) | undefined; + + if (!originalMethod || typeof originalMethod !== "function") { + throw new Error(`@Unmemoize can only be applied to methods`); + } + + const wrappedMethod = async function (this: unknown, ...args: unknown[]): Promise { + const result = await originalMethod.apply(this, args); + + try { + const redis = getRedisService(); + const keysToInvalidate = config.keys(...args) as string[]; + await Promise.allSettled(keysToInvalidate.map((key) => redis.del(key))); + } catch (error) { + log.warn("Cache invalidation failed", { + message: error instanceof Error ? error.message : "Unknown error", + }); + } + + return result; + }; + + descriptor.value = wrappedMethod as T; + + return descriptor; + }; +} diff --git a/packages/features/cache/decorators/__tests__/Memoize.test.ts b/packages/features/cache/decorators/__tests__/Memoize.test.ts new file mode 100644 index 0000000000..419509b72a --- /dev/null +++ b/packages/features/cache/decorators/__tests__/Memoize.test.ts @@ -0,0 +1,203 @@ +import type { IRedisService } from "@calcom/features/redis/IRedisService"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { Memoize } from "../Memoize"; +import { DEFAULT_TTL_MS } from "../types"; + +const createMockRedis = (): IRedisService => ({ + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + expire: vi.fn(), + lrange: vi.fn(), + lpush: vi.fn(), +}); + +let mockRedis: IRedisService; + +vi.mock("@calcom/features/di/containers/Redis", () => ({ + getRedisService: () => mockRedis, +})); + +describe("Memoize decorator", () => { + beforeEach(() => { + mockRedis = createMockRedis(); + vi.clearAllMocks(); + }); + + it("should return cached value on cache hit", async () => { + const cachedValue = { id: 1, name: "test" }; + vi.mocked(mockRedis.get).mockResolvedValue(cachedValue); + + class TestRepository { + @Memoize({ key: (id: number) => `test:${id}` }) + async findById(id: number): Promise<{ id: number; name: string } | null> { + return { id, name: "from-db" }; + } + } + + const repo = new TestRepository(); + const result = await repo.findById(1); + + expect(result).toEqual(cachedValue); + expect(mockRedis.get).toHaveBeenCalledWith("test:1"); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); + + it("should fetch from source and cache on cache miss", async () => { + vi.mocked(mockRedis.get).mockResolvedValue(null); + vi.mocked(mockRedis.set).mockResolvedValue("OK"); + + class TestRepository { + @Memoize({ key: (id: number) => `test:${id}` }) + async findById(id: number): Promise<{ id: number; name: string } | null> { + return { id, name: "from-db" }; + } + } + + const repo = new TestRepository(); + const result = await repo.findById(1); + + expect(result).toEqual({ id: 1, name: "from-db" }); + expect(mockRedis.get).toHaveBeenCalledWith("test:1"); + expect(mockRedis.set).toHaveBeenCalledWith("test:1", { id: 1, name: "from-db" }, { ttl: DEFAULT_TTL_MS }); + }); + + it("should use custom TTL when provided", async () => { + vi.mocked(mockRedis.get).mockResolvedValue(null); + vi.mocked(mockRedis.set).mockResolvedValue("OK"); + + const customTtl = 10000; + + class TestRepository { + @Memoize({ key: (id: number) => `test:${id}`, ttl: customTtl }) + async findById(id: number): Promise<{ id: number; name: string } | null> { + return { id, name: "from-db" }; + } + } + + const repo = new TestRepository(); + await repo.findById(1); + + expect(mockRedis.set).toHaveBeenCalledWith("test:1", { id: 1, name: "from-db" }, { ttl: customTtl }); + }); + + it("should not cache null results", async () => { + vi.mocked(mockRedis.get).mockResolvedValue(null); + + class TestRepository { + @Memoize({ key: (id: number) => `test:${id}` }) + async findById(_id: number): Promise<{ id: number; name: string } | null> { + return null; + } + } + + const repo = new TestRepository(); + const result = await repo.findById(1); + + expect(result).toBeNull(); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); + + it("should validate cached data with Zod schema", async () => { + const schema = z.object({ + id: z.number(), + name: z.string(), + }); + + const validCachedValue = { id: 1, name: "test" }; + vi.mocked(mockRedis.get).mockResolvedValue(validCachedValue); + + class TestRepository { + @Memoize({ key: (id: number) => `test:${id}`, schema }) + async findById(id: number): Promise<{ id: number; name: string } | null> { + return { id, name: "from-db" }; + } + } + + const repo = new TestRepository(); + const result = await repo.findById(1); + + expect(result).toEqual(validCachedValue); + }); + + it("should fetch from source when cached data fails Zod validation", async () => { + const schema = z.object({ + id: z.number(), + name: z.string(), + }); + + const invalidCachedValue = { id: "not-a-number", name: 123 }; + vi.mocked(mockRedis.get).mockResolvedValue(invalidCachedValue); + vi.mocked(mockRedis.set).mockResolvedValue("OK"); + + class TestRepository { + @Memoize({ key: (id: number) => `test:${id}`, schema }) + async findById(id: number): Promise<{ id: number; name: string } | null> { + return { id, name: "from-db" }; + } + } + + const repo = new TestRepository(); + const result = await repo.findById(1); + + expect(result).toEqual({ id: 1, name: "from-db" }); + expect(mockRedis.set).toHaveBeenCalled(); + }); + + it("should generate correct cache key with multiple arguments", async () => { + vi.mocked(mockRedis.get).mockResolvedValue(null); + vi.mocked(mockRedis.set).mockResolvedValue("OK"); + + class TestRepository { + @Memoize({ key: (userId: number, featureId: string) => `features:user:${userId}:${featureId}` }) + async findByUserIdAndFeatureId( + userId: number, + featureId: string + ): Promise<{ userId: number; featureId: string } | null> { + return { userId, featureId }; + } + } + + const repo = new TestRepository(); + await repo.findByUserIdAndFeatureId(123, "feature-abc"); + + expect(mockRedis.get).toHaveBeenCalledWith("features:user:123:feature-abc"); + }); + + it("should preserve method context (this binding)", async () => { + vi.mocked(mockRedis.get).mockResolvedValue(null); + vi.mocked(mockRedis.set).mockResolvedValue("OK"); + + class TestRepository { + prefix = "custom"; + + @Memoize({ key: (id: number) => `test:${id}` }) + async findById(id: number): Promise { + return `${this.prefix}-${id}`; + } + } + + const repo = new TestRepository(); + const result = await repo.findById(1); + + expect(result).toBe("custom-1"); + }); + + it("should not cache undefined results", async () => { + vi.mocked(mockRedis.get).mockResolvedValue(null); + + class TestRepository { + @Memoize({ key: (id: number) => `test:${id}` }) + async findById(_id: number): Promise<{ id: number } | undefined> { + return undefined; + } + } + + const repo = new TestRepository(); + const result = await repo.findById(1); + + expect(result).toBeUndefined(); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/features/cache/decorators/__tests__/Unmemoize.test.ts b/packages/features/cache/decorators/__tests__/Unmemoize.test.ts new file mode 100644 index 0000000000..fddfaa8033 --- /dev/null +++ b/packages/features/cache/decorators/__tests__/Unmemoize.test.ts @@ -0,0 +1,196 @@ +import type { IRedisService } from "@calcom/features/redis/IRedisService"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Unmemoize } from "../Unmemoize"; + +const createMockRedis = (): IRedisService => ({ + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + expire: vi.fn(), + lrange: vi.fn(), + lpush: vi.fn(), +}); + +let mockRedis: IRedisService; + +vi.mock("@calcom/features/di/containers/Redis", () => ({ + getRedisService: () => mockRedis, +})); + +describe("Unmemoize decorator", () => { + beforeEach(() => { + mockRedis = createMockRedis(); + vi.clearAllMocks(); + }); + + it("should invalidate cache keys after method execution", async () => { + vi.mocked(mockRedis.del).mockResolvedValue(1); + + class TestRepository { + @Unmemoize({ keys: (id: number) => [`test:${id}`] }) + async delete(_id: number): Promise { + return; + } + } + + const repo = new TestRepository(); + await repo.delete(1); + + expect(mockRedis.del).toHaveBeenCalledWith("test:1"); + }); + + it("should invalidate multiple cache keys", async () => { + vi.mocked(mockRedis.del).mockResolvedValue(1); + + class TestRepository { + @Unmemoize({ keys: (id: number) => [`test:${id}`, `test:all`, `test:list:${id}`] }) + async delete(_id: number): Promise { + return; + } + } + + const repo = new TestRepository(); + await repo.delete(1); + + expect(mockRedis.del).toHaveBeenCalledTimes(3); + expect(mockRedis.del).toHaveBeenCalledWith("test:1"); + expect(mockRedis.del).toHaveBeenCalledWith("test:all"); + expect(mockRedis.del).toHaveBeenCalledWith("test:list:1"); + }); + + it("should return the method result correctly", async () => { + vi.mocked(mockRedis.del).mockResolvedValue(1); + + class TestRepository { + @Unmemoize({ keys: (id: number) => [`test:${id}`] }) + async update(id: number): Promise<{ id: number; updated: boolean }> { + return { id, updated: true }; + } + } + + const repo = new TestRepository(); + const result = await repo.update(1); + + expect(result).toEqual({ id: 1, updated: true }); + }); + + it("should generate correct cache keys with multiple arguments", async () => { + vi.mocked(mockRedis.del).mockResolvedValue(1); + + class TestRepository { + @Unmemoize({ + keys: (userId: number, featureId: string) => [`features:user:${userId}:${featureId}`], + }) + async upsert(userId: number, featureId: string): Promise<{ userId: number; featureId: string }> { + return { userId, featureId }; + } + } + + const repo = new TestRepository(); + await repo.upsert(123, "feature-abc"); + + expect(mockRedis.del).toHaveBeenCalledWith("features:user:123:feature-abc"); + }); + + it("should preserve method context (this binding)", async () => { + vi.mocked(mockRedis.del).mockResolvedValue(1); + + class TestRepository { + prefix = "custom"; + + @Unmemoize({ keys: (id: number) => [`test:${id}`] }) + async update(id: number): Promise { + return `${this.prefix}-${id}`; + } + } + + const repo = new TestRepository(); + const result = await repo.update(1); + + expect(result).toBe("custom-1"); + }); + + it("should invalidate cache even when method returns null", async () => { + vi.mocked(mockRedis.del).mockResolvedValue(1); + + class TestRepository { + @Unmemoize({ keys: (id: number) => [`test:${id}`] }) + async delete(_id: number): Promise { + return null; + } + } + + const repo = new TestRepository(); + const result = await repo.delete(1); + + expect(result).toBeNull(); + expect(mockRedis.del).toHaveBeenCalledWith("test:1"); + }); + + it("should invalidate cache even when method returns undefined", async () => { + vi.mocked(mockRedis.del).mockResolvedValue(1); + + class TestRepository { + @Unmemoize({ keys: (id: number) => [`test:${id}`] }) + async delete(_id: number): Promise { + return undefined; + } + } + + const repo = new TestRepository(); + await repo.delete(1); + + expect(mockRedis.del).toHaveBeenCalledWith("test:1"); + }); + + it("should execute method before invalidating cache", async () => { + const executionOrder: string[] = []; + + vi.mocked(mockRedis.del).mockImplementation(async () => { + executionOrder.push("cache-invalidated"); + return 1; + }); + + class TestRepository { + @Unmemoize({ keys: (id: number) => [`test:${id}`] }) + async update(id: number): Promise<{ id: number }> { + executionOrder.push("method-executed"); + return { id }; + } + } + + const repo = new TestRepository(); + await repo.update(1); + + expect(executionOrder).toEqual(["method-executed", "cache-invalidated"]); + }); + + it("should handle empty keys array", async () => { + class TestRepository { + @Unmemoize({ keys: () => [] }) + async update(id: number): Promise<{ id: number }> { + return { id }; + } + } + + const repo = new TestRepository(); + const result = await repo.update(1); + + expect(result).toEqual({ id: 1 }); + expect(mockRedis.del).not.toHaveBeenCalled(); + }); + + it("should propagate errors from the original method", async () => { + class TestRepository { + @Unmemoize({ keys: (id: number) => [`test:${id}`] }) + async update(_id: number): Promise<{ id: number }> { + throw new Error("Database error"); + } + } + + const repo = new TestRepository(); + + await expect(repo.update(1)).rejects.toThrow("Database error"); + expect(mockRedis.del).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/features/cache/decorators/types.ts b/packages/features/cache/decorators/types.ts new file mode 100644 index 0000000000..caa31464b1 --- /dev/null +++ b/packages/features/cache/decorators/types.ts @@ -0,0 +1,3 @@ +const DEFAULT_TTL_MS: number = 5 * 60 * 1000; // 5 minutes + +export { DEFAULT_TTL_MS }; diff --git a/packages/features/cache/index.ts b/packages/features/cache/index.ts new file mode 100644 index 0000000000..09f4d63ffc --- /dev/null +++ b/packages/features/cache/index.ts @@ -0,0 +1,3 @@ +export { Memoize } from "./decorators/Memoize"; +export { DEFAULT_TTL_MS } from "./decorators/types"; +export { Unmemoize } from "./decorators/Unmemoize"; diff --git a/packages/features/di/containers/FeatureRepository.ts b/packages/features/di/containers/FeatureRepository.ts new file mode 100644 index 0000000000..1adf85f125 --- /dev/null +++ b/packages/features/di/containers/FeatureRepository.ts @@ -0,0 +1,10 @@ +import type { IFeatureRepository } from "@calcom/features/flags/repositories/FeatureRepository"; +import { moduleLoader as featureRepositoryModuleLoader } from "../../flags/di/FeatureRepository.module"; +import { type Container, createContainer } from "../di"; + +const featureRepositoryContainer: Container = createContainer(); + +export function getFeatureRepository(): IFeatureRepository { + featureRepositoryModuleLoader.loadModule(featureRepositoryContainer); + return featureRepositoryContainer.get(featureRepositoryModuleLoader.token); +} diff --git a/packages/features/di/containers/Redis.ts b/packages/features/di/containers/Redis.ts new file mode 100644 index 0000000000..6016749d6c --- /dev/null +++ b/packages/features/di/containers/Redis.ts @@ -0,0 +1,11 @@ +import { DI_TOKENS } from "@calcom/features/di/tokens"; +import { redisModule } from "@calcom/features/redis/di/redisModule"; +import type { IRedisService } from "@calcom/features/redis/IRedisService"; +import { type Container, createContainer } from "../di"; + +const container: Container = createContainer(); +container.load(DI_TOKENS.REDIS_CLIENT, redisModule); + +export function getRedisService(): IRedisService { + return container.get(DI_TOKENS.REDIS_CLIENT); +} diff --git a/packages/features/di/containers/TeamFeatureRepository.ts b/packages/features/di/containers/TeamFeatureRepository.ts new file mode 100644 index 0000000000..9da1f257dc --- /dev/null +++ b/packages/features/di/containers/TeamFeatureRepository.ts @@ -0,0 +1,10 @@ +import type { ITeamFeatureRepository } from "@calcom/features/flags/repositories/TeamFeatureRepository"; +import { moduleLoader as teamFeatureRepositoryModuleLoader } from "../../flags/di/TeamFeatureRepository.module"; +import { createContainer } from "../di"; + +const teamFeatureRepositoryContainer = createContainer(); + +export function getTeamFeatureRepository(): ITeamFeatureRepository { + teamFeatureRepositoryModuleLoader.loadModule(teamFeatureRepositoryContainer); + return teamFeatureRepositoryContainer.get(teamFeatureRepositoryModuleLoader.token); +} diff --git a/packages/features/di/containers/UserFeatureRepository.ts b/packages/features/di/containers/UserFeatureRepository.ts new file mode 100644 index 0000000000..ee7e77ffef --- /dev/null +++ b/packages/features/di/containers/UserFeatureRepository.ts @@ -0,0 +1,10 @@ +import type { IUserFeatureRepository } from "@calcom/features/flags/repositories/UserFeatureRepository"; +import { moduleLoader as userFeatureRepositoryModuleLoader } from "../../flags/di/UserFeatureRepository.module"; +import { createContainer } from "../di"; + +const userFeatureRepositoryContainer = createContainer(); + +export function getUserFeatureRepository(): IUserFeatureRepository { + userFeatureRepositoryModuleLoader.loadModule(userFeatureRepositoryContainer); + return userFeatureRepositoryContainer.get(userFeatureRepositoryModuleLoader.token); +} diff --git a/packages/features/di/modules/FeatureOptInService.ts b/packages/features/di/modules/FeatureOptInService.ts index da26b07e3b..4d90cf325b 100644 --- a/packages/features/di/modules/FeatureOptInService.ts +++ b/packages/features/di/modules/FeatureOptInService.ts @@ -1,19 +1,25 @@ 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 { moduleLoader as featureRepositoryModuleLoader } from "@calcom/features/flags/di/FeatureRepository.module"; +import { moduleLoader as teamFeatureRepositoryModuleLoader } from "@calcom/features/flags/di/TeamFeatureRepository.module"; +import { moduleLoader as userFeatureRepositoryModuleLoader } from "@calcom/features/flags/di/UserFeatureRepository.module"; +import type { Module } from "@evyweb/ioctopus"; 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 thisModule: Module = createModule(); +const token: symbol = FEATURE_OPT_IN_DI_TOKENS.FEATURE_OPT_IN_SERVICE; +const moduleToken: symbol = FEATURE_OPT_IN_DI_TOKENS.FEATURE_OPT_IN_SERVICE_MODULE; -const loadModule = bindModuleToClassOnToken({ +const loadModule: ReturnType = bindModuleToClassOnToken({ module: thisModule, moduleToken, token, classs: FeatureOptInService, - dep: featuresRepositoryModuleLoader, + depsMap: { + featureRepo: featureRepositoryModuleLoader, + teamFeatureRepo: teamFeatureRepositoryModuleLoader, + userFeatureRepo: userFeatureRepositoryModuleLoader, + }, }); export const moduleLoader: ModuleLoader = { diff --git a/packages/features/feature-opt-in/services/FeatureOptInService.test.ts b/packages/features/feature-opt-in/services/FeatureOptInService.test.ts index 95ebb33e3b..4ae35abfb9 100644 --- a/packages/features/feature-opt-in/services/FeatureOptInService.test.ts +++ b/packages/features/feature-opt-in/services/FeatureOptInService.test.ts @@ -1,13 +1,17 @@ -import type { FeatureState } from "@calcom/features/flags/config"; -import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import type { IFeatureRepository } from "@calcom/features/flags/repositories/FeatureRepository"; +import type { ITeamFeatureRepository } from "@calcom/features/flags/repositories/TeamFeatureRepository"; +import type { IUserFeatureRepository } from "@calcom/features/flags/repositories/UserFeatureRepository"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { ErrorWithCode } from "@calcom/lib/errors"; +import type { TeamFeatures, UserFeatures } from "@calcom/prisma/client"; +import type { Mock } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { IFeatureOptInServiceDeps } from "./FeatureOptInService"; import { FeatureOptInService } from "./FeatureOptInService"; -const mockIsFeatureAllowedForScope = vi.fn(); -const mockFindAllByUserId = vi.fn(); +const mockIsFeatureAllowedForScope: Mock = vi.fn(); +const mockFindAllByUserId: Mock = vi.fn(); // Mock the OPT_IN_FEATURES config vi.mock("../config", () => { @@ -50,7 +54,7 @@ vi.mock("../config", () => { OPT_IN_FEATURES: mockFeatures, isOptInFeature: (slug: string) => mockFeatures.some((feature) => feature.slug === slug), getOptInFeaturesForScope: () => mockFeatures, - get isFeatureAllowedForScope() { + get isFeatureAllowedForScope(): Mock { return mockIsFeatureAllowedForScope; }, getOptInFeatureConfig: (slug: string) => mockFeatures.find((feature) => feature.slug === slug), @@ -60,27 +64,27 @@ vi.mock("../config", () => { // Mock MembershipRepository vi.mock("@calcom/features/membership/repositories/MembershipRepository", () => ({ MembershipRepository: class { - findAllByUserId(...args: unknown[]) { + findAllByUserId(...args: unknown[]): unknown { return mockFindAllByUserId(...args); } }, })); // Mock PermissionCheckService - needs to be a class that can be instantiated -let mockCheckPermission = vi.fn(); +let mockCheckPermission: Mock = vi.fn(); vi.mock("@calcom/features/pbac/services/permission-check.service", () => ({ PermissionCheckService: class { - checkPermission(...args: unknown[]) { + checkPermission(...args: unknown[]): unknown { return mockCheckPermission(...args); } }, })); // Mock TeamRepository - needs to be a class that can be instantiated -let mockFindOwnedTeamsByUserId = vi.fn(); +let mockFindOwnedTeamsByUserId: Mock = vi.fn(); vi.mock("@calcom/features/ee/teams/repositories/TeamRepository", () => ({ TeamRepository: class { - findOwnedTeamsByUserId(...args: unknown[]) { + findOwnedTeamsByUserId(...args: unknown[]): unknown { return mockFindOwnedTeamsByUserId(...args); } }, @@ -91,39 +95,96 @@ vi.mock("@calcom/prisma", () => ({ prisma: {}, })); -describe("FeatureOptInService", () => { - let mockFeaturesRepository: { - getAllFeatures: ReturnType; - getTeamsFeatureStates: ReturnType; - setUserFeatureState: ReturnType; - setTeamFeatureState: ReturnType; +// Helper to create mock TeamFeatures +function createMockTeamFeature(teamId: number, featureId: string, enabled: boolean): TeamFeatures { + return { + teamId, + featureId, + enabled, + assignedBy: "test", + updatedAt: new Date(), }; +} + +// Helper to create mock UserFeatures +function createMockUserFeature(userId: number, featureId: string, enabled: boolean): UserFeatures { + return { + userId, + featureId, + enabled, + assignedBy: "test", + updatedAt: new Date(), + }; +} + +describe("FeatureOptInService", () => { + let mockFeatureRepo: { + findAll: ReturnType; + }; + let mockTeamFeatureRepo: { + findByTeamIdAndFeatureId: ReturnType; + findByTeamIdsAndFeatureIds: ReturnType; + upsert: ReturnType; + delete: ReturnType; + findAutoOptInByTeamId: ReturnType; + findAutoOptInByTeamIds: ReturnType; + setAutoOptIn: ReturnType; + }; + let mockUserFeatureRepo: { + findByUserIdAndFeatureId: ReturnType; + findByUserIdAndFeatureIds: ReturnType; + upsert: ReturnType; + delete: ReturnType; + findAutoOptInByUserId: ReturnType; + setAutoOptIn: ReturnType; + }; + let mockDeps: IFeatureOptInServiceDeps; let service: FeatureOptInService; beforeEach(() => { vi.resetAllMocks(); mockIsFeatureAllowedForScope.mockReturnValue(true); - mockFeaturesRepository = { - getAllFeatures: vi.fn(), - getTeamsFeatureStates: vi.fn(), - setUserFeatureState: vi.fn(), - setTeamFeatureState: vi.fn(), + mockFeatureRepo = { + findAll: vi.fn(), + }; + mockTeamFeatureRepo = { + findByTeamIdAndFeatureId: vi.fn(), + findByTeamIdsAndFeatureIds: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), + findAutoOptInByTeamId: vi.fn(), + findAutoOptInByTeamIds: vi.fn(), + setAutoOptIn: vi.fn(), + }; + mockUserFeatureRepo = { + findByUserIdAndFeatureId: vi.fn(), + findByUserIdAndFeatureIds: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), + findAutoOptInByUserId: vi.fn(), + setAutoOptIn: vi.fn(), }; - service = new FeatureOptInService(mockFeaturesRepository as unknown as FeaturesRepository); + mockDeps = { + featureRepo: mockFeatureRepo as unknown as IFeatureRepository, + teamFeatureRepo: mockTeamFeatureRepo as unknown as ITeamFeatureRepository, + userFeatureRepo: mockUserFeatureRepo as unknown as IUserFeatureRepository, + }; + + service = new FeatureOptInService(mockDeps); }); describe("listFeaturesForTeam", () => { it("should return features with team state when no parent org", async () => { - mockFeaturesRepository.getAllFeatures.mockResolvedValue([ + mockFeatureRepo.findAll.mockResolvedValue([ { slug: "test-feature-1", enabled: true }, { slug: "test-feature-2", enabled: true }, ]); - mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({ - "test-feature-1": { 1: "enabled" as FeatureState }, - "test-feature-2": { 1: "disabled" as FeatureState }, + mockTeamFeatureRepo.findByTeamIdsAndFeatureIds.mockResolvedValue({ + "test-feature-1": { 1: createMockTeamFeature(1, "test-feature-1", true) }, + "test-feature-2": { 1: createMockTeamFeature(1, "test-feature-2", false) }, }); const result = await service.listFeaturesForTeam({ teamId: 1 }); @@ -143,29 +204,34 @@ describe("FeatureOptInService", () => { }); // Verify that only the team ID was queried (no parent org) - expect(mockFeaturesRepository.getTeamsFeatureStates).toHaveBeenCalledWith({ - teamIds: [1], - featureIds: [ + expect(mockTeamFeatureRepo.findByTeamIdsAndFeatureIds).toHaveBeenCalledWith( + [1], + [ "test-feature-1", "test-feature-2", "org-only-feature", "team-only-feature", "user-only-feature", "strict-feature", - ], - }); + ] + ); }); it("should return features with team and org state when parent org exists", async () => { - mockFeaturesRepository.getAllFeatures.mockResolvedValue([ + mockFeatureRepo.findAll.mockResolvedValue([ { slug: "test-feature-1", enabled: true }, { slug: "test-feature-2", enabled: true }, ]); // Team ID is 1, Parent Org ID is 100 - mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({ - "test-feature-1": { 1: "enabled" as FeatureState, 100: "disabled" as FeatureState }, - "test-feature-2": { 1: "inherit" as FeatureState, 100: "enabled" as FeatureState }, + mockTeamFeatureRepo.findByTeamIdsAndFeatureIds.mockResolvedValue({ + "test-feature-1": { + 1: createMockTeamFeature(1, "test-feature-1", true), + 100: createMockTeamFeature(100, "test-feature-1", false), + }, + "test-feature-2": { + 100: createMockTeamFeature(100, "test-feature-2", true), + }, }); const result = await service.listFeaturesForTeam({ teamId: 1, parentOrgId: 100 }); @@ -185,25 +251,25 @@ describe("FeatureOptInService", () => { }); // Verify that both team ID and parent org ID were queried - expect(mockFeaturesRepository.getTeamsFeatureStates).toHaveBeenCalledWith({ - teamIds: [1, 100], - featureIds: [ + expect(mockTeamFeatureRepo.findByTeamIdsAndFeatureIds).toHaveBeenCalledWith( + [1, 100], + [ "test-feature-1", "test-feature-2", "org-only-feature", "team-only-feature", "user-only-feature", "strict-feature", - ], - }); + ] + ); }); it("should return inherit for org state when parent org has no explicit state", async () => { - mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); + mockFeatureRepo.findAll.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); // Team ID is 1, Parent Org ID is 100, but org has no explicit state - mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({ - "test-feature-1": { 1: "enabled" as FeatureState }, + mockTeamFeatureRepo.findByTeamIdsAndFeatureIds.mockResolvedValue({ + "test-feature-1": { 1: createMockTeamFeature(1, "test-feature-1", true) }, }); const result = await service.listFeaturesForTeam({ teamId: 1, parentOrgId: 100 }); @@ -218,14 +284,14 @@ describe("FeatureOptInService", () => { }); it("should filter out globally disabled features", async () => { - mockFeaturesRepository.getAllFeatures.mockResolvedValue([ + mockFeatureRepo.findAll.mockResolvedValue([ { slug: "test-feature-1", enabled: true }, { slug: "test-feature-2", enabled: false }, ]); - mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({ - "test-feature-1": { 1: "enabled" as FeatureState }, - "test-feature-2": { 1: "enabled" as FeatureState }, + mockTeamFeatureRepo.findByTeamIdsAndFeatureIds.mockResolvedValue({ + "test-feature-1": { 1: createMockTeamFeature(1, "test-feature-1", true) }, + "test-feature-2": { 1: createMockTeamFeature(1, "test-feature-2", true) }, }); const result = await service.listFeaturesForTeam({ teamId: 1 }); @@ -235,9 +301,9 @@ describe("FeatureOptInService", () => { }); it("should return inherit for team state when team has no explicit state", async () => { - mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); + mockFeatureRepo.findAll.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); - mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({ + mockTeamFeatureRepo.findByTeamIdsAndFeatureIds.mockResolvedValue({ "test-feature-1": {}, }); @@ -253,10 +319,10 @@ describe("FeatureOptInService", () => { }); it("should handle null parentOrgId the same as undefined", async () => { - mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); + mockFeatureRepo.findAll.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); - mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({ - "test-feature-1": { 1: "enabled" as FeatureState }, + mockTeamFeatureRepo.findByTeamIdsAndFeatureIds.mockResolvedValue({ + "test-feature-1": { 1: createMockTeamFeature(1, "test-feature-1", true) }, }); const result = await service.listFeaturesForTeam({ teamId: 1, parentOrgId: null }); @@ -265,24 +331,24 @@ describe("FeatureOptInService", () => { expect(result[0].orgState).toBe("inherit"); // Verify that only the team ID was queried (no parent org) - expect(mockFeaturesRepository.getTeamsFeatureStates).toHaveBeenCalledWith({ - teamIds: [1], - featureIds: [ + expect(mockTeamFeatureRepo.findByTeamIdsAndFeatureIds).toHaveBeenCalledWith( + [1], + [ "test-feature-1", "test-feature-2", "org-only-feature", "team-only-feature", "user-only-feature", "strict-feature", - ], - }); + ] + ); }); }); describe("setUserFeatureState", () => { it("should set user feature state when scope allows", async () => { mockIsFeatureAllowedForScope.mockReturnValue(true); - mockFeaturesRepository.setUserFeatureState.mockResolvedValue(undefined); + mockUserFeatureRepo.upsert.mockResolvedValue(createMockUserFeature(1, "test-feature-1", true)); await service.setUserFeatureState({ userId: 1, @@ -291,17 +357,12 @@ describe("FeatureOptInService", () => { assignedBy: 2, }); - expect(mockFeaturesRepository.setUserFeatureState).toHaveBeenCalledWith({ - userId: 1, - featureId: "test-feature-1", - state: "enabled", - assignedBy: "user-2", - }); + expect(mockUserFeatureRepo.upsert).toHaveBeenCalledWith(1, "test-feature-1", true, "user-2"); }); it("should set user feature state to inherit when scope allows", async () => { mockIsFeatureAllowedForScope.mockReturnValue(true); - mockFeaturesRepository.setUserFeatureState.mockResolvedValue(undefined); + mockUserFeatureRepo.delete.mockResolvedValue(undefined); await service.setUserFeatureState({ userId: 1, @@ -309,11 +370,7 @@ describe("FeatureOptInService", () => { state: "inherit", }); - expect(mockFeaturesRepository.setUserFeatureState).toHaveBeenCalledWith({ - userId: 1, - featureId: "test-feature-1", - state: "inherit", - }); + expect(mockUserFeatureRepo.delete).toHaveBeenCalledWith(1, "test-feature-1"); }); it("should throw ErrorWithCode when feature is not allowed at user scope", async () => { @@ -340,7 +397,7 @@ describe("FeatureOptInService", () => { message: 'Feature "org-only-feature" is not available at the user scope', }); - expect(mockFeaturesRepository.setUserFeatureState).not.toHaveBeenCalled(); + expect(mockUserFeatureRepo.upsert).not.toHaveBeenCalled(); }); it("should validate scope before setting inherit state", async () => { @@ -354,14 +411,14 @@ describe("FeatureOptInService", () => { }) ).rejects.toThrow(ErrorWithCode); - expect(mockFeaturesRepository.setUserFeatureState).not.toHaveBeenCalled(); + expect(mockUserFeatureRepo.delete).not.toHaveBeenCalled(); }); }); describe("setTeamFeatureState", () => { it("should set team feature state when scope allows", async () => { mockIsFeatureAllowedForScope.mockReturnValue(true); - mockFeaturesRepository.setTeamFeatureState.mockResolvedValue(undefined); + mockTeamFeatureRepo.upsert.mockResolvedValue(createMockTeamFeature(1, "test-feature-1", true)); await service.setTeamFeatureState({ teamId: 1, @@ -371,17 +428,12 @@ describe("FeatureOptInService", () => { scope: "team", }); - expect(mockFeaturesRepository.setTeamFeatureState).toHaveBeenCalledWith({ - teamId: 1, - featureId: "test-feature-1", - state: "enabled", - assignedBy: "user-2", - }); + expect(mockTeamFeatureRepo.upsert).toHaveBeenCalledWith(1, "test-feature-1", true, "user-2"); }); it("should set team feature state to inherit when scope allows", async () => { mockIsFeatureAllowedForScope.mockReturnValue(true); - mockFeaturesRepository.setTeamFeatureState.mockResolvedValue(undefined); + mockTeamFeatureRepo.delete.mockResolvedValue(undefined); await service.setTeamFeatureState({ teamId: 1, @@ -390,11 +442,7 @@ describe("FeatureOptInService", () => { scope: "team", }); - expect(mockFeaturesRepository.setTeamFeatureState).toHaveBeenCalledWith({ - teamId: 1, - featureId: "test-feature-1", - state: "inherit", - }); + expect(mockTeamFeatureRepo.delete).toHaveBeenCalledWith(1, "test-feature-1"); }); it("should throw ErrorWithCode when feature is not allowed at team scope", async () => { @@ -423,7 +471,7 @@ describe("FeatureOptInService", () => { message: 'Feature "user-only-feature" is not available at the team scope', }); - expect(mockFeaturesRepository.setTeamFeatureState).not.toHaveBeenCalled(); + expect(mockTeamFeatureRepo.upsert).not.toHaveBeenCalled(); }); it("should throw ErrorWithCode when feature is not allowed at org scope", async () => { @@ -452,12 +500,12 @@ describe("FeatureOptInService", () => { message: 'Feature "team-only-feature" is not available at the org scope', }); - expect(mockFeaturesRepository.setTeamFeatureState).not.toHaveBeenCalled(); + expect(mockTeamFeatureRepo.upsert).not.toHaveBeenCalled(); }); it("should default to team scope when scope is not provided", async () => { mockIsFeatureAllowedForScope.mockReturnValue(true); - mockFeaturesRepository.setTeamFeatureState.mockResolvedValue(undefined); + mockTeamFeatureRepo.upsert.mockResolvedValue(createMockTeamFeature(1, "test-feature-1", true)); await service.setTeamFeatureState({ teamId: 1, @@ -481,7 +529,7 @@ describe("FeatureOptInService", () => { }) ).rejects.toThrow(ErrorWithCode); - expect(mockFeaturesRepository.setTeamFeatureState).not.toHaveBeenCalled(); + expect(mockTeamFeatureRepo.delete).not.toHaveBeenCalled(); }); }); @@ -492,15 +540,34 @@ describe("FeatureOptInService", () => { mockCheckPermission = vi.fn(); mockFindOwnedTeamsByUserId = vi.fn(); - mockFeaturesRepository = { - getAllFeatures: vi.fn(), - getTeamsFeatureStates: vi.fn(), - getUserFeatureStates: vi.fn().mockResolvedValue({}), - getUserAutoOptIn: vi.fn().mockResolvedValue(false), - getTeamsAutoOptIn: vi.fn().mockResolvedValue({}), + mockFeatureRepo = { + findAll: vi.fn(), + }; + mockTeamFeatureRepo = { + findByTeamIdAndFeatureId: vi.fn(), + findByTeamIdsAndFeatureIds: vi.fn().mockResolvedValue({}), + upsert: vi.fn(), + delete: vi.fn(), + findAutoOptInByTeamId: vi.fn(), + findAutoOptInByTeamIds: vi.fn().mockResolvedValue({}), + setAutoOptIn: vi.fn(), + }; + mockUserFeatureRepo = { + findByUserIdAndFeatureId: vi.fn(), + findByUserIdAndFeatureIds: vi.fn().mockResolvedValue({}), + upsert: vi.fn(), + delete: vi.fn(), + findAutoOptInByUserId: vi.fn().mockResolvedValue(false), + setAutoOptIn: vi.fn(), }; - service = new FeatureOptInService(mockFeaturesRepository as unknown as FeaturesRepository); + mockDeps = { + featureRepo: mockFeatureRepo as unknown as IFeatureRepository, + teamFeatureRepo: mockTeamFeatureRepo as unknown as ITeamFeatureRepository, + userFeatureRepo: mockUserFeatureRepo as unknown as IUserFeatureRepository, + }; + + service = new FeatureOptInService(mockDeps); }); it("should return invalid_feature for non-opt-in features", async () => { @@ -519,8 +586,8 @@ describe("FeatureOptInService", () => { it("should return feature_disabled when feature is globally disabled", async () => { mockFindAllByUserId.mockResolvedValue([]); - mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: false }]); - mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({}); + mockFeatureRepo.findAll.mockResolvedValue([{ slug: "test-feature-1", enabled: false }]); + mockTeamFeatureRepo.findByTeamIdsAndFeatureIds.mockResolvedValue({}); const result = await service.checkFeatureOptInEligibility({ userId: 1, @@ -537,10 +604,10 @@ describe("FeatureOptInService", () => { it("should return already_enabled when feature is already enabled for user", async () => { mockFindAllByUserId.mockResolvedValue([]); - mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); - mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({}); - mockFeaturesRepository.getUserFeatureStates.mockResolvedValue({ - "test-feature-1": "enabled" as FeatureState, + mockFeatureRepo.findAll.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); + mockTeamFeatureRepo.findByTeamIdsAndFeatureIds.mockResolvedValue({}); + mockUserFeatureRepo.findByUserIdAndFeatureIds.mockResolvedValue({ + "test-feature-1": createMockUserFeature(1, "test-feature-1", true), }); mockFindOwnedTeamsByUserId.mockResolvedValue([]); @@ -561,9 +628,9 @@ describe("FeatureOptInService", () => { mockFindAllByUserId.mockResolvedValue([ { teamId: 100, role: "MEMBER", team: { id: 100, isOrganization: true, parentId: null } }, ]); - mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); - mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({ - "test-feature-1": { 100: "disabled" as FeatureState }, + mockFeatureRepo.findAll.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); + mockTeamFeatureRepo.findByTeamIdsAndFeatureIds.mockResolvedValue({ + "test-feature-1": { 100: createMockTeamFeature(100, "test-feature-1", false) }, }); mockFindOwnedTeamsByUserId.mockResolvedValue([]); @@ -586,8 +653,8 @@ describe("FeatureOptInService", () => { mockFindAllByUserId.mockResolvedValue([ { teamId: 1, role: "MEMBER", team: { id: 1, isOrganization: false, parentId: null } }, ]); - mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); - mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({}); + mockFeatureRepo.findAll.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); + mockTeamFeatureRepo.findByTeamIdsAndFeatureIds.mockResolvedValue({}); mockFindOwnedTeamsByUserId.mockResolvedValue([]); const result = await service.checkFeatureOptInEligibility({ @@ -610,8 +677,8 @@ describe("FeatureOptInService", () => { { teamId: 1, role: "ADMIN", team: { id: 1, isOrganization: false, parentId: null } }, { teamId: 2, role: "MEMBER", team: { id: 2, isOrganization: false, parentId: null } }, ]); - mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); - mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({}); + mockFeatureRepo.findAll.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); + mockTeamFeatureRepo.findByTeamIdsAndFeatureIds.mockResolvedValue({}); mockFindOwnedTeamsByUserId.mockResolvedValue([{ id: 1, name: "Team 1", isOrganization: false }]); const result = await service.checkFeatureOptInEligibility({ @@ -643,7 +710,7 @@ describe("FeatureOptInService", () => { vi.doMock("@calcom/features/membership/repositories/MembershipRepository", () => ({ MembershipRepository: class { - findAllByUserId(...args: unknown[]) { + findAllByUserId(...args: unknown[]): unknown { return mockMembershipRepository.findAllByUserId(...args); } }, diff --git a/packages/features/feature-opt-in/services/FeatureOptInService.ts b/packages/features/feature-opt-in/services/FeatureOptInService.ts index c33ed1ee47..75b9cd98c6 100644 --- a/packages/features/feature-opt-in/services/FeatureOptInService.ts +++ b/packages/features/feature-opt-in/services/FeatureOptInService.ts @@ -1,13 +1,16 @@ import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import type { FeatureId, FeatureState } from "@calcom/features/flags/config"; -import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import type { IFeatureRepository } from "@calcom/features/flags/repositories/FeatureRepository"; +import type { ITeamFeatureRepository } from "@calcom/features/flags/repositories/TeamFeatureRepository"; +import type { IUserFeatureRepository } from "@calcom/features/flags/repositories/UserFeatureRepository"; import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import type { TeamFeaturesDto } from "@calcom/lib/dto/TeamFeaturesDto"; +import type { UserFeaturesDto } from "@calcom/lib/dto/UserFeaturesDto"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { ErrorWithCode } from "@calcom/lib/errors"; import { prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; - import { getOptInFeatureConfig, getOptInFeaturesForScope, @@ -25,6 +28,24 @@ import type { UserRoleContext, } from "./IFeatureOptInService"; +function teamFeatureToState(teamFeature: TeamFeaturesDto | undefined): FeatureState { + if (!teamFeature) return "inherit"; + if (teamFeature.enabled) return "enabled"; + return "disabled"; +} + +function userFeatureToState(userFeature: UserFeaturesDto | undefined): FeatureState { + if (!userFeature) return "inherit"; + if (userFeature.enabled) return "enabled"; + return "disabled"; +} + +interface IFeatureOptInServiceDeps { + featureRepo: IFeatureRepository; + teamFeatureRepo: ITeamFeatureRepository; + userFeatureRepo: IUserFeatureRepository; +} + type ListFeaturesForUserResult = { featureId: FeatureId; globalEnabled: boolean; @@ -52,9 +73,9 @@ type FeatureOptInEligibilityStatus = | "blocked" | "can_opt_in"; -function getOrgState(orgId: number | null, teamStatesById: Record): FeatureState { +function getOrgState(orgId: number | null, teamStatesById: Record): FeatureState { if (orgId !== null) { - return teamStatesById[orgId] ?? "inherit"; + return teamFeatureToState(teamStatesById[orgId]); } return "inherit"; } @@ -75,11 +96,11 @@ function getTeamIdsToQuery(teamId: number, parentOrgId: number | null | undefine function getOrgStateForTeam( parentOrgId: number | null | undefined, - teamStates: Record>, + teamStates: Partial>>, slug: FeatureId ): FeatureState { if (parentOrgId) { - return teamStates[slug]?.[parentOrgId] ?? "inherit"; + return teamFeatureToState(teamStates[slug]?.[parentOrgId]); } return "inherit"; } @@ -89,7 +110,7 @@ function getOrgStateForTeam( * Computes effective states based on global, org, team, and user settings. */ export class FeatureOptInService implements IFeatureOptInService { - constructor(private featuresRepository: FeaturesRepository) {} + constructor(private deps: IFeatureOptInServiceDeps) {} /** * Core method: Resolve feature states for a user across all their teams. @@ -125,11 +146,11 @@ export class FeatureOptInService implements IFeatureOptInService { } const [allFeatures, allTeamStates, userStates, userAutoOptIn, teamsAutoOptIn] = await Promise.all([ - this.featuresRepository.getAllFeatures(), - this.featuresRepository.getTeamsFeatureStates({ teamIds: allTeamIds, featureIds }), - this.featuresRepository.getUserFeatureStates({ userId, featureIds }), - this.featuresRepository.getUserAutoOptIn(userId), - this.featuresRepository.getTeamsAutoOptIn(allTeamIds), + this.deps.featureRepo.findAll(), + this.deps.teamFeatureRepo.findByTeamIdsAndFeatureIds(allTeamIds, featureIds), + this.deps.userFeatureRepo.findByUserIdAndFeatureIds(userId, featureIds), + this.deps.userFeatureRepo.findAutoOptInByUserId(userId), + this.deps.teamFeatureRepo.findAutoOptInByTeamIds(allTeamIds), ]); const globalEnabledMap = new Map(allFeatures.map((feature) => [feature.slug, feature.enabled ?? false])); @@ -157,8 +178,8 @@ export class FeatureOptInService implements IFeatureOptInService { orgId: number | null, teamIds: number[], globalEnabledMap: Map, - allTeamStates: Record>, - userStates: Record, + allTeamStates: Partial>>, + userStates: Partial>, teamsAutoOptIn: Record, userAutoOptIn: boolean ): ResolvedFeatureState { @@ -166,8 +187,8 @@ export class FeatureOptInService implements IFeatureOptInService { const teamStatesById = allTeamStates[featureId] ?? {}; const orgState = getOrgState(orgId, teamStatesById); - const teamStates = teamIds.map((teamId) => teamStatesById[teamId] ?? "inherit"); - const userState = userStates[featureId] ?? "inherit"; + const teamStates = teamIds.map((teamId) => teamFeatureToState(teamStatesById[teamId])); + const userState = userFeatureToState(userStates[featureId]); const orgAutoOptIn = getOrgAutoOptIn(orgId, teamsAutoOptIn); const teamAutoOptIns = teamIds.map((teamId) => teamsAutoOptIn[teamId] ?? false); @@ -245,17 +266,17 @@ export class FeatureOptInService implements IFeatureOptInService { const scopedFeatures = getOptInFeaturesForScope(scope); const [allFeatures, teamStates] = await Promise.all([ - this.featuresRepository.getAllFeatures(), - this.featuresRepository.getTeamsFeatureStates({ - teamIds: teamIdsToQuery, - featureIds: scopedFeatures.map((config) => config.slug), - }), + this.deps.featureRepo.findAll(), + this.deps.teamFeatureRepo.findByTeamIdsAndFeatureIds( + teamIdsToQuery, + scopedFeatures.map((config) => config.slug) + ), ]); const results = scopedFeatures.map((config) => { const globalFeature = allFeatures.find((f) => f.slug === config.slug); const globalEnabled = globalFeature?.enabled ?? false; - const teamState = teamStates[config.slug]?.[teamId] ?? "inherit"; + const teamState = teamFeatureToState(teamStates[config.slug]?.[teamId]); const orgState = getOrgStateForTeam(parentOrgId, teamStates, config.slug); return { featureId: config.slug, globalEnabled, teamState, orgState }; @@ -445,21 +466,15 @@ export class FeatureOptInService implements IFeatureOptInService { } if (state === "inherit") { - await this.featuresRepository.setUserFeatureState({ userId, featureId, state }); + await this.deps.userFeatureRepo.delete(userId, featureId); } else { const { assignedBy } = input; - await this.featuresRepository.setUserFeatureState({ - userId, - featureId, - state, - assignedBy: `user-${assignedBy}`, - }); + await this.deps.userFeatureRepo.upsert(userId, featureId, state === "enabled", `user-${assignedBy}`); } } /** * Set team's feature state. - * Delegates to FeaturesRepository.setTeamFeatureState. * Throws an error if the feature is not scoped to the specified scope. */ async setTeamFeatureState( @@ -484,18 +499,14 @@ export class FeatureOptInService implements IFeatureOptInService { } if (state === "inherit") { - await this.featuresRepository.setTeamFeatureState({ teamId, featureId, state }); + await this.deps.teamFeatureRepo.delete(teamId, featureId); } else { const { assignedBy } = input; - await this.featuresRepository.setTeamFeatureState({ - teamId, - featureId, - state, - assignedBy: `user-${assignedBy}`, - }); + await this.deps.teamFeatureRepo.upsert(teamId, featureId, state === "enabled", `user-${assignedBy}`); } } } export type { FeatureOptInEligibilityStatus }; -export type { UserRoleContext, FeatureOptInEligibilityResult } from "./IFeatureOptInService"; +export type { IFeatureOptInServiceDeps }; +export type { FeatureOptInEligibilityResult, UserRoleContext } from "./IFeatureOptInService"; diff --git a/packages/features/flags/DECORATOR_IMPLEMENTATION_PLAN.md b/packages/features/flags/DECORATOR_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000000..3d67efa1fc --- /dev/null +++ b/packages/features/flags/DECORATOR_IMPLEMENTATION_PLAN.md @@ -0,0 +1,290 @@ +# Memoize/Unmemoize Decorator Implementation Plan + +## Overview + +This plan outlines the implementation of `@Memoize` and `@Unmemoize` decorators to replace the current CachedXXRepository + PrismaXXRepository + RedisXXRepository pattern (9 classes per 3 entities) with a more declarative approach. + +## Current State + +The current implementation uses three layers per entity: +- `PrismaUserFeatureRepository` - Database access +- `RedisUserFeatureRepository` - Cache operations (get/set/invalidate) +- `CachedUserFeatureRepository` - Orchestrates cache-aside pattern + +This results in 9 repository classes for 3 entities (Feature, TeamFeature, UserFeature), with repetitive cache-aside logic in each CachedXXRepository. + +## Target State + +A single repository class per entity with decorators handling caching: + +```typescript +class UserFeatureRepository { + @Memoize({ key: (userId, featureId) => `features:user:${userId}:${featureId}`, schema: userFeaturesSchema }) + async findByUserIdAndFeatureId(userId: number, featureId: FeatureId): Promise { + return this.prisma.userFeatures.findUnique({ where: { userId_featureId: { userId, featureId } } }); + } + + @Unmemoize({ keys: (userId, featureId) => [`features:user:${userId}:${featureId}`] }) + async upsert(userId: number, featureId: FeatureId, enabled: boolean, assignedBy: string): Promise { + return this.prisma.userFeatures.upsert({ ... }); + } +} +``` + +## Implementation Steps + +### Step 1: Enable Decorators in tsconfig (if needed) + +The `packages/features/tsconfig.json` extends `@calcom/tsconfig/react-library.json`. We need to add `experimentalDecorators: true` to enable legacy decorators. + +**Note**: Many packages already have `experimentalDecorators: true` (apps/web, apps/api/v2, packages/trpc, etc.), so this is consistent with the codebase. + +### Step 2: Create Decorator Infrastructure + +Create new files in `packages/features/cache/`: + +1. **`packages/features/cache/decorators/Memoize.ts`** + - Decorator that wraps methods with cache-aside logic + - Accepts configuration: `key`, `ttl`, `schema` (Zod) + - Uses `IRedisService` for cache operations + - Validates cached data with Zod schema before returning + +2. **`packages/features/cache/decorators/Unmemoize.ts`** + - Decorator that invalidates cache keys after method execution + - Accepts configuration: `keys` (function returning array of cache keys) + - Invalidates specified keys after the wrapped method completes + +3. **`packages/features/cache/decorators/index.ts`** + - Re-exports both decorators + +4. **`packages/features/cache/decorators/types.ts`** + - Shared types for decorator configuration + +### Step 3: Implement @Memoize Decorator + +```typescript +interface MemoizeConfig { + key: (...args: TArgs) => string; + ttl?: number; + schema?: ZodSchema; +} + +function Memoize(config: MemoizeConfig) { + return function ( + target: (this: T, ...args: TArgs) => Promise, + context: ClassMethodDecoratorContext + ) { + return async function (this: T, ...args: TArgs) { + const cacheKey = config.key(...args); + const cached = await this.redis.get(cacheKey); + + if (cached !== null) { + if (config.schema) { + const parsed = config.schema.safeParse(cached); + if (parsed.success) return parsed.data; + } else { + return cached; + } + } + + const result = await target.call(this, ...args); + if (result !== null) { + await this.redis.set(cacheKey, result, { ttl: config.ttl ?? DEFAULT_TTL }); + } + return result; + }; + }; +} +``` + +### Step 4: Implement @Unmemoize Decorator + +```typescript +interface UnmemoizeConfig { + keys: (...args: TArgs) => string[]; +} + +function Unmemoize(config: UnmemoizeConfig) { + return function ( + target: (this: T, ...args: TArgs) => Promise, + context: ClassMethodDecoratorContext + ) { + return async function (this: T, ...args: TArgs) { + const result = await target.call(this, ...args); + const keysToInvalidate = config.keys(...args); + await Promise.all(keysToInvalidate.map((key) => this.redis.del(key))); + return result; + }; + }; +} +``` + +### Step 5: Refactor UserFeatureRepository + +Create a new unified `UserFeatureRepository` that uses decorators: + +```typescript +export class UserFeatureRepository implements IUserFeatureRepository { + constructor( + private prisma: PrismaClient, + private redis: IRedisService + ) {} + + async findByUserId(userId: number): Promise { + return this.prisma.userFeatures.findMany({ where: { userId } }); + } + + @Memoize({ + key: (userId: number, featureId: FeatureId) => `features:user:${userId}:${featureId}`, + schema: userFeaturesSchema, + }) + async findByUserIdAndFeatureId(userId: number, featureId: FeatureId): Promise { + return this.prisma.userFeatures.findUnique({ + where: { userId_featureId: { userId, featureId } }, + }); + } + + @Memoize({ + key: (userId: number) => `features:user:autoOptIn:${userId}`, + schema: booleanSchema, + }) + async findAutoOptInByUserId(userId: number): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { autoOptInFeatures: true }, + }); + return user?.autoOptInFeatures ?? false; + } + + @Unmemoize({ + keys: (userId: number, featureId: FeatureId) => [`features:user:${userId}:${featureId}`], + }) + async upsert(userId: number, featureId: FeatureId, enabled: boolean, assignedBy: string): Promise { + return this.prisma.userFeatures.upsert({ + where: { userId_featureId: { userId, featureId } }, + create: { userId, featureId, enabled, assignedBy }, + update: { enabled, assignedBy }, + }); + } + + @Unmemoize({ + keys: (userId: number, featureId: FeatureId) => [`features:user:${userId}:${featureId}`], + }) + async delete(userId: number, featureId: FeatureId): Promise { + await this.prisma.userFeatures.delete({ + where: { userId_featureId: { userId, featureId } }, + }); + } + + @Unmemoize({ + keys: (userId: number) => [`features:user:autoOptIn:${userId}`], + }) + async updateAutoOptIn(userId: number, enabled: boolean): Promise { + await this.prisma.user.update({ + where: { id: userId }, + data: { autoOptInFeatures: enabled }, + }); + } + + // Methods without caching (complex queries) + async checkIfUserBelongsToTeamWithFeature(userId: number, slug: string): Promise { + // ... existing implementation + } + + async checkIfUserBelongsToTeamWithFeatureNonHierarchical(userId: number, slug: string): Promise { + // ... existing implementation + } +} +``` + +### Step 6: Update DI Configuration + +Update the DI module to use the new unified repository: + +```typescript +// packages/features/flags/di/UserFeatureRepository.module.ts +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: UserFeatureRepository, + depsMap: { + prisma: prismaModuleLoader, + redis: redisModuleLoader, + }, +}); +``` + +### Step 7: Write Tests + +Create comprehensive tests for the decorators: + +1. **`packages/features/cache/decorators/__tests__/Memoize.test.ts`** + - Test cache hit returns cached value + - Test cache miss fetches from source and caches + - Test Zod validation on cached data + - Test null results are not cached + - Test TTL is respected + +2. **`packages/features/cache/decorators/__tests__/Unmemoize.test.ts`** + - Test cache keys are invalidated after method execution + - Test multiple keys can be invalidated + - Test method result is returned correctly + +## File Structure + +``` +packages/features/ + cache/ + decorators/ + __tests__/ + Memoize.test.ts + Unmemoize.test.ts + Memoize.ts + Unmemoize.ts + types.ts + index.ts + flags/ + repositories/ + UserFeatureRepository.ts (new unified repository) + # Keep existing files for backward compatibility during migration + di/ + UserFeatureRepository.module.ts (new module) + tokens.ts (add new token) +``` + +## Migration Strategy + +1. Create the new decorator infrastructure alongside existing code +2. Create the new unified `UserFeatureRepository` with decorators +3. Add new DI module for the unified repository +4. Update consumers to use the new repository +5. Remove old CachedXXRepository, RedisXXRepository files after migration is complete + +## Benefits + +1. **Reduced boilerplate**: 1 class instead of 3 per entity +2. **Declarative caching**: Cache behavior is visible at method level +3. **Co-located logic**: Caching and invalidation rules are next to the methods they affect +4. **Type-safe**: Full TypeScript support with proper typing +5. **Testable**: Decorators can be tested independently +6. **Consistent**: Same pattern for all repositories + +## Considerations + +1. **Decorator limitations**: Legacy decorators (experimentalDecorators) are used since TC39 decorators have different semantics +2. **Redis dependency**: Repository classes must have `redis: IRedisService` property for decorators to work +3. **Zod validation**: Optional but recommended for cached data integrity +4. **TTL**: Default 5 minutes, configurable per method + +## Timeline + +1. Step 1-2: Create decorator infrastructure (~30 min) +2. Step 3-4: Implement decorators (~30 min) +3. Step 5: Refactor UserFeatureRepository (~30 min) +4. Step 6: Update DI configuration (~15 min) +5. Step 7: Write tests (~45 min) +6. Type checks and lint (~15 min) +7. Create PR (~10 min) + +Total estimated time: ~3 hours diff --git a/packages/features/flags/di/FeatureRepository.module.ts b/packages/features/flags/di/FeatureRepository.module.ts new file mode 100644 index 0000000000..ac1a6ba7b7 --- /dev/null +++ b/packages/features/flags/di/FeatureRepository.module.ts @@ -0,0 +1,23 @@ +import { createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; +import type { Module } from "@evyweb/ioctopus"; +import { FeatureRepository } from "../repositories/FeatureRepository"; +import { FLAGS_DI_TOKENS } from "./tokens"; + +const thisModule: Module = createModule(); +const token: symbol = FLAGS_DI_TOKENS.FEATURE_REPOSITORY; +const moduleToken: symbol = FLAGS_DI_TOKENS.FEATURE_REPOSITORY_MODULE; + +thisModule.bind(token).toClass(FeatureRepository, [prismaModuleLoader.token]); + +const loadModule = (container: ReturnType): void => { + container.load(moduleToken, thisModule); + prismaModuleLoader.loadModule(container); +}; + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { FeatureRepository }; diff --git a/packages/features/flags/di/TeamFeatureRepository.module.ts b/packages/features/flags/di/TeamFeatureRepository.module.ts new file mode 100644 index 0000000000..36a99ae665 --- /dev/null +++ b/packages/features/flags/di/TeamFeatureRepository.module.ts @@ -0,0 +1,23 @@ +import { createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; +import type { Module } from "@evyweb/ioctopus"; +import { TeamFeatureRepository } from "../repositories/TeamFeatureRepository"; +import { FLAGS_DI_TOKENS } from "./tokens"; + +const thisModule: Module = createModule(); +const token: symbol = FLAGS_DI_TOKENS.TEAM_FEATURE_REPOSITORY; +const moduleToken: symbol = FLAGS_DI_TOKENS.TEAM_FEATURE_REPOSITORY_MODULE; + +thisModule.bind(token).toClass(TeamFeatureRepository, [prismaModuleLoader.token]); + +const loadModule = (container: ReturnType): void => { + container.load(moduleToken, thisModule); + prismaModuleLoader.loadModule(container); +}; + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { TeamFeatureRepository }; diff --git a/packages/features/flags/di/UserFeatureRepository.module.ts b/packages/features/flags/di/UserFeatureRepository.module.ts new file mode 100644 index 0000000000..e63639ba17 --- /dev/null +++ b/packages/features/flags/di/UserFeatureRepository.module.ts @@ -0,0 +1,25 @@ +import type { Module } from "@evyweb/ioctopus"; + +import { createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; + +import { UserFeatureRepository } from "../repositories/UserFeatureRepository"; +import { FLAGS_DI_TOKENS } from "./tokens"; + +const thisModule: Module = createModule(); +const token: symbol = FLAGS_DI_TOKENS.USER_FEATURE_REPOSITORY; +const moduleToken: symbol = FLAGS_DI_TOKENS.USER_FEATURE_REPOSITORY_MODULE; + +thisModule.bind(token).toClass(UserFeatureRepository, [prismaModuleLoader.token]); + +const loadModule = (container: ReturnType): void => { + container.load(moduleToken, thisModule); + prismaModuleLoader.loadModule(container); +}; + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { UserFeatureRepository }; diff --git a/packages/features/flags/di/tokens.ts b/packages/features/flags/di/tokens.ts index 63ce604c1b..d3ad7522b7 100644 --- a/packages/features/flags/di/tokens.ts +++ b/packages/features/flags/di/tokens.ts @@ -1,4 +1,22 @@ -export const FLAGS_DI_TOKENS = { +export const FLAGS_DI_TOKENS: { + FEATURES_REPOSITORY: symbol; + FEATURES_REPOSITORY_MODULE: symbol; + FEATURE_REPOSITORY: symbol; + FEATURE_REPOSITORY_MODULE: symbol; + TEAM_FEATURE_REPOSITORY: symbol; + TEAM_FEATURE_REPOSITORY_MODULE: symbol; + USER_FEATURE_REPOSITORY: symbol; + USER_FEATURE_REPOSITORY_MODULE: symbol; +} = { FEATURES_REPOSITORY: Symbol("FeaturesRepository"), FEATURES_REPOSITORY_MODULE: Symbol("FeaturesRepositoryModule"), + + FEATURE_REPOSITORY: Symbol("FeatureRepository"), + FEATURE_REPOSITORY_MODULE: Symbol("FeatureRepositoryModule"), + + TEAM_FEATURE_REPOSITORY: Symbol("TeamFeatureRepository"), + TEAM_FEATURE_REPOSITORY_MODULE: Symbol("TeamFeatureRepositoryModule"), + + USER_FEATURE_REPOSITORY: Symbol("UserFeatureRepository"), + USER_FEATURE_REPOSITORY_MODULE: Symbol("UserFeatureRepositoryModule"), }; diff --git a/packages/features/flags/repositories/FeatureRepository.ts b/packages/features/flags/repositories/FeatureRepository.ts new file mode 100644 index 0000000000..aca8eb5c85 --- /dev/null +++ b/packages/features/flags/repositories/FeatureRepository.ts @@ -0,0 +1,42 @@ +import { Memoize } from "@calcom/features/cache"; +import type { FeatureDto } from "@calcom/lib/dto/FeatureDto"; +import { FeatureDtoArraySchema } from "@calcom/lib/dto/FeatureDto"; +import type { PrismaClient } from "@calcom/prisma/client"; + +const CACHE_PREFIX = "features:global"; +const KEY = { + all: (): string => `${CACHE_PREFIX}:all`, +}; + +export interface IFeatureRepository { + findAll(): Promise; +} + +export class FeatureRepository implements IFeatureRepository { + private prisma: PrismaClient; + + constructor(prisma: PrismaClient) { + this.prisma = prisma; + } + + @Memoize({ + key: () => KEY.all(), + schema: FeatureDtoArraySchema, + }) + async findAll(): Promise { + const features = await this.prisma.feature.findMany({ + orderBy: { slug: "asc" }, + }); + return features.map((f) => ({ + slug: f.slug, + enabled: f.enabled, + description: f.description, + type: f.type, + stale: f.stale, + lastUsedAt: f.lastUsedAt, + createdAt: f.createdAt, + updatedAt: f.updatedAt, + updatedBy: f.updatedBy, + })); + } +} diff --git a/packages/features/flags/repositories/TeamFeatureRepository.ts b/packages/features/flags/repositories/TeamFeatureRepository.ts new file mode 100644 index 0000000000..bcdc5fcac5 --- /dev/null +++ b/packages/features/flags/repositories/TeamFeatureRepository.ts @@ -0,0 +1,190 @@ +import { Memoize, Unmemoize } from "@calcom/features/cache"; +import type { TeamFeaturesDto } from "@calcom/lib/dto/TeamFeaturesDto"; +import { TeamFeaturesDtoSchema } from "@calcom/lib/dto/TeamFeaturesDto"; +import type { PrismaClient } from "@calcom/prisma/client"; +import type { FeatureId } from "../config"; +import { booleanSchema } from "./schemas"; + +const CACHE_PREFIX = "features:team"; +const KEY = { + byTeamIdAndFeatureId: (teamId: number, featureId: string): string => + `${CACHE_PREFIX}:${teamId}:${featureId}`, + autoOptInByTeamId: (teamId: number): string => `${CACHE_PREFIX}:autoOptIn:${teamId}`, +}; + +export interface ITeamFeatureRepository { + findByTeamIdAndFeatureId(teamId: number, featureId: FeatureId): Promise; + findByTeamIdsAndFeatureIds( + teamIds: number[], + featureIds: FeatureId[] + ): Promise>>>; + upsert( + teamId: number, + featureId: FeatureId, + enabled: boolean, + assignedBy: string + ): Promise; + delete(teamId: number, featureId: FeatureId): Promise; + findAutoOptInByTeamId(teamId: number): Promise; + findAutoOptInByTeamIds(teamIds: number[]): Promise>; + setAutoOptIn(teamId: number, enabled: boolean): Promise; +} + +export class TeamFeatureRepository implements ITeamFeatureRepository { + private prisma: PrismaClient; + + constructor(prisma: PrismaClient) { + this.prisma = prisma; + } + + @Memoize({ + key: (teamId: number, featureId: FeatureId) => KEY.byTeamIdAndFeatureId(teamId, featureId), + schema: TeamFeaturesDtoSchema, + }) + async findByTeamIdAndFeatureId(teamId: number, featureId: FeatureId): Promise { + const result = await this.prisma.teamFeatures.findUnique({ + where: { + teamId_featureId: { + teamId, + featureId, + }, + }, + }); + if (!result) return null; + return this.toDto(result); + } + + async findByTeamIdsAndFeatureIds( + teamIds: number[], + featureIds: FeatureId[] + ): Promise>>> { + if (teamIds.length === 0 || featureIds.length === 0) { + return {}; + } + + const results = await Promise.all( + teamIds.flatMap((teamId) => + featureIds.map(async (featureId) => { + const teamFeature = await this.findByTeamIdAndFeatureId(teamId, featureId); + return { teamId, featureId, teamFeature }; + }) + ) + ); + + const result: Partial>> = {}; + for (const { teamId, featureId, teamFeature } of results) { + if (teamFeature !== null) { + if (!result[featureId]) { + result[featureId] = {}; + } + const featureRecord = result[featureId]; + if (featureRecord) { + featureRecord[teamId] = teamFeature; + } + } + } + + return result; + } + + @Unmemoize({ + keys: (teamId: number, featureId: FeatureId) => [KEY.byTeamIdAndFeatureId(teamId, featureId)], + }) + async upsert( + teamId: number, + featureId: FeatureId, + enabled: boolean, + assignedBy: string + ): Promise { + const result = await this.prisma.teamFeatures.upsert({ + where: { + teamId_featureId: { + teamId, + featureId, + }, + }, + create: { + teamId, + featureId, + enabled, + assignedBy, + }, + update: { + enabled, + assignedBy, + }, + }); + return this.toDto(result); + } + + private toDto(teamFeature: { + teamId: number; + featureId: string; + enabled: boolean; + assignedBy: string; + updatedAt: Date; + }): TeamFeaturesDto { + return { + teamId: teamFeature.teamId, + featureId: teamFeature.featureId, + enabled: teamFeature.enabled, + assignedBy: teamFeature.assignedBy, + updatedAt: teamFeature.updatedAt, + }; + } + + @Unmemoize({ + keys: (teamId: number, featureId: FeatureId) => [KEY.byTeamIdAndFeatureId(teamId, featureId)], + }) + async delete(teamId: number, featureId: FeatureId): Promise { + await this.prisma.teamFeatures.delete({ + where: { + teamId_featureId: { + teamId, + featureId, + }, + }, + }); + } + + @Memoize({ + key: (teamId: number) => KEY.autoOptInByTeamId(teamId), + schema: booleanSchema, + }) + async findAutoOptInByTeamId(teamId: number): Promise { + const team = await this.prisma.team.findUnique({ + where: { id: teamId }, + select: { autoOptInFeatures: true }, + }); + return team?.autoOptInFeatures ?? false; + } + + async findAutoOptInByTeamIds(teamIds: number[]): Promise> { + if (teamIds.length === 0) { + return {}; + } + + const results = await Promise.all( + teamIds.map(async (teamId) => { + const autoOptIn = await this.findAutoOptInByTeamId(teamId); + return { teamId, autoOptIn }; + }) + ); + + const result: Record = {}; + for (const { teamId, autoOptIn } of results) { + result[teamId] = autoOptIn; + } + return result; + } + + @Unmemoize({ + keys: (teamId: number) => [KEY.autoOptInByTeamId(teamId)], + }) + async setAutoOptIn(teamId: number, enabled: boolean): Promise { + await this.prisma.team.update({ + where: { id: teamId }, + data: { autoOptInFeatures: enabled }, + }); + } +} diff --git a/packages/features/flags/repositories/UserFeatureRepository.ts b/packages/features/flags/repositories/UserFeatureRepository.ts new file mode 100644 index 0000000000..ac82c932fc --- /dev/null +++ b/packages/features/flags/repositories/UserFeatureRepository.ts @@ -0,0 +1,158 @@ +import { Memoize, Unmemoize } from "@calcom/features/cache"; +import type { UserFeaturesDto } from "@calcom/lib/dto/UserFeaturesDto"; +import { UserFeaturesDtoSchema } from "@calcom/lib/dto/UserFeaturesDto"; +import type { PrismaClient } from "@calcom/prisma/client"; +import type { FeatureId } from "../config"; +import { booleanSchema } from "./schemas"; + +const CACHE_PREFIX = "features:user"; +const KEY = { + byUserIdAndFeatureId: (userId: number, featureId: string): string => + `${CACHE_PREFIX}:${userId}:${featureId}`, + autoOptInByUserId: (userId: number): string => `${CACHE_PREFIX}:autoOptIn:${userId}`, +}; + +export interface IUserFeatureRepository { + findByUserIdAndFeatureId(userId: number, featureId: FeatureId): Promise; + findByUserIdAndFeatureIds( + userId: number, + featureIds: FeatureId[] + ): Promise>>; + upsert( + userId: number, + featureId: FeatureId, + enabled: boolean, + assignedBy: string + ): Promise; + delete(userId: number, featureId: FeatureId): Promise; + findAutoOptInByUserId(userId: number): Promise; + setAutoOptIn(userId: number, enabled: boolean): Promise; +} + +export class UserFeatureRepository implements IUserFeatureRepository { + private prisma: PrismaClient; + + constructor(prisma: PrismaClient) { + this.prisma = prisma; + } + + @Memoize({ + key: (userId: number, featureId: FeatureId) => KEY.byUserIdAndFeatureId(userId, featureId), + schema: UserFeaturesDtoSchema, + }) + async findByUserIdAndFeatureId(userId: number, featureId: FeatureId): Promise { + const result = await this.prisma.userFeatures.findUnique({ + where: { + userId_featureId: { + userId, + featureId, + }, + }, + }); + if (!result) return null; + return this.toDto(result); + } + + async findByUserIdAndFeatureIds( + userId: number, + featureIds: FeatureId[] + ): Promise>> { + const results = await Promise.all( + featureIds.map(async (featureId) => { + const userFeature = await this.findByUserIdAndFeatureId(userId, featureId); + return { featureId, userFeature }; + }) + ); + + const result: Partial> = {}; + for (const { featureId, userFeature } of results) { + if (userFeature !== null) { + result[featureId] = userFeature; + } + } + + return result; + } + + @Unmemoize({ + keys: (userId: number, featureId: FeatureId) => [KEY.byUserIdAndFeatureId(userId, featureId)], + }) + async upsert( + userId: number, + featureId: FeatureId, + enabled: boolean, + assignedBy: string + ): Promise { + const result = await this.prisma.userFeatures.upsert({ + where: { + userId_featureId: { + userId, + featureId, + }, + }, + create: { + userId, + featureId, + enabled, + assignedBy, + }, + update: { + enabled, + assignedBy, + }, + }); + return this.toDto(result); + } + + private toDto(userFeature: { + userId: number; + featureId: string; + enabled: boolean; + assignedBy: string; + updatedAt: Date; + }): UserFeaturesDto { + return { + userId: userFeature.userId, + featureId: userFeature.featureId, + enabled: userFeature.enabled, + assignedBy: userFeature.assignedBy, + updatedAt: userFeature.updatedAt, + }; + } + + @Unmemoize({ + keys: (userId: number, featureId: FeatureId) => [KEY.byUserIdAndFeatureId(userId, featureId)], + }) + async delete(userId: number, featureId: FeatureId): Promise { + await this.prisma.userFeatures.delete({ + where: { + userId_featureId: { + userId, + featureId, + }, + }, + }); + } + + @Memoize({ + key: (userId: number) => KEY.autoOptInByUserId(userId), + schema: booleanSchema, + }) + async findAutoOptInByUserId(userId: number): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { autoOptInFeatures: true }, + }); + return user?.autoOptInFeatures ?? false; + } + + @Unmemoize({ + keys: (userId: number) => [KEY.autoOptInByUserId(userId)], + }) + async setAutoOptIn(userId: number, enabled: boolean): Promise { + await this.prisma.user.update({ + where: { id: userId }, + data: { autoOptInFeatures: enabled }, + }); + } +} diff --git a/packages/features/flags/repositories/__tests__/FeatureRepository.test.ts b/packages/features/flags/repositories/__tests__/FeatureRepository.test.ts new file mode 100644 index 0000000000..78792cf74f --- /dev/null +++ b/packages/features/flags/repositories/__tests__/FeatureRepository.test.ts @@ -0,0 +1,100 @@ +import type { IRedisService } from "@calcom/features/redis/IRedisService"; +import type { PrismaClient } from "@calcom/prisma/client"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { FeatureRepository } from "../FeatureRepository"; + +interface MockPrisma { + feature: { + findMany: ReturnType; + }; +} + +function createMockRedis(): IRedisService { + return { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + expire: vi.fn(), + lrange: vi.fn(), + lpush: vi.fn(), + }; +} + +function createMockPrisma(): MockPrisma { + return { + feature: { + findMany: vi.fn(), + }, + }; +} + +let mockRedis: IRedisService; + +vi.mock("@calcom/features/di/containers/Redis", () => ({ + getRedisService: () => mockRedis, +})); + +describe("FeatureRepository", () => { + let mockPrisma: MockPrisma; + let repository: FeatureRepository; + + beforeEach(() => { + mockRedis = createMockRedis(); + mockPrisma = createMockPrisma(); + repository = new FeatureRepository(mockPrisma as unknown as PrismaClient); + vi.clearAllMocks(); + }); + + describe("findAll", () => { + it("should return cached features on cache hit", async () => { + const cachedFeatures = [ + { + slug: "feature-1", + enabled: true, + description: "Test feature", + type: "RELEASE", + stale: false, + lastUsedAt: null, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + updatedBy: null, + }, + ]; + vi.mocked(mockRedis.get).mockResolvedValue(cachedFeatures); + + const result = await repository.findAll(); + + expect(result).toEqual(cachedFeatures); + expect(mockRedis.get).toHaveBeenCalledWith("features:global:all"); + expect(mockPrisma.feature.findMany).not.toHaveBeenCalled(); + }); + + it("should fetch from database and cache on cache miss", async () => { + const dbFeatures = [ + { + slug: "feature-1", + enabled: true, + description: "Test feature", + type: "RELEASE", + stale: false, + lastUsedAt: null, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + updatedBy: null, + }, + ]; + vi.mocked(mockRedis.get).mockResolvedValue(null); + vi.mocked(mockPrisma.feature.findMany).mockResolvedValue(dbFeatures); + vi.mocked(mockRedis.set).mockResolvedValue("OK"); + + const result = await repository.findAll(); + + expect(result).toEqual(dbFeatures); + expect(mockRedis.get).toHaveBeenCalledWith("features:global:all"); + expect(mockPrisma.feature.findMany).toHaveBeenCalledWith({ + orderBy: { slug: "asc" }, + }); + expect(mockRedis.set).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/features/flags/repositories/__tests__/TeamFeatureRepository.test.ts b/packages/features/flags/repositories/__tests__/TeamFeatureRepository.test.ts new file mode 100644 index 0000000000..4aaeef23e1 --- /dev/null +++ b/packages/features/flags/repositories/__tests__/TeamFeatureRepository.test.ts @@ -0,0 +1,271 @@ +import type { IRedisService } from "@calcom/features/redis/IRedisService"; +import type { PrismaClient } from "@calcom/prisma/client"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TeamFeatureRepository } from "../TeamFeatureRepository"; + +interface MockPrisma { + teamFeatures: { + findUnique: ReturnType; + upsert: ReturnType; + delete: ReturnType; + }; + team: { + findUnique: ReturnType; + }; +} + +function createMockRedis(): IRedisService { + return { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + expire: vi.fn(), + lrange: vi.fn(), + lpush: vi.fn(), + }; +} + +function createMockPrisma(): MockPrisma { + return { + teamFeatures: { + findUnique: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), + }, + team: { + findUnique: vi.fn(), + }, + }; +} + +let mockRedis: IRedisService; + +vi.mock("@calcom/features/di/containers/Redis", () => ({ + getRedisService: () => mockRedis, +})); + +describe("TeamFeatureRepository", () => { + let mockPrisma: MockPrisma; + let repository: TeamFeatureRepository; + + beforeEach(() => { + mockRedis = createMockRedis(); + mockPrisma = createMockPrisma(); + repository = new TeamFeatureRepository(mockPrisma as unknown as PrismaClient); + vi.clearAllMocks(); + }); + + describe("findByTeamIdAndFeatureId", () => { + it("should return cached team feature on cache hit", async () => { + const cachedFeature = { + teamId: 1, + featureId: "feature-1", + enabled: true, + assignedBy: "admin", + updatedAt: new Date("2024-01-01"), + }; + vi.mocked(mockRedis.get).mockResolvedValue(cachedFeature); + + const result = await repository.findByTeamIdAndFeatureId(1, "feature-1"); + + expect(result).toEqual(cachedFeature); + expect(mockRedis.get).toHaveBeenCalledWith("features:team:1:feature-1"); + expect(mockPrisma.teamFeatures.findUnique).not.toHaveBeenCalled(); + }); + + it("should fetch from database and cache on cache miss", async () => { + const dbFeature = { + teamId: 1, + featureId: "feature-1", + enabled: true, + assignedBy: "admin", + updatedAt: new Date("2024-01-01"), + }; + vi.mocked(mockRedis.get).mockResolvedValue(null); + vi.mocked(mockPrisma.teamFeatures.findUnique).mockResolvedValue(dbFeature); + vi.mocked(mockRedis.set).mockResolvedValue("OK"); + + const result = await repository.findByTeamIdAndFeatureId(1, "feature-1"); + + expect(result).toEqual(dbFeature); + expect(mockPrisma.teamFeatures.findUnique).toHaveBeenCalledWith({ + where: { + teamId_featureId: { + teamId: 1, + featureId: "feature-1", + }, + }, + }); + expect(mockRedis.set).toHaveBeenCalled(); + }); + + it("should return null when team feature not found", async () => { + vi.mocked(mockRedis.get).mockResolvedValue(null); + vi.mocked(mockPrisma.teamFeatures.findUnique).mockResolvedValue(null); + + const result = await repository.findByTeamIdAndFeatureId(1, "non-existent"); + + expect(result).toBeNull(); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); + }); + + describe("findByTeamIdsAndFeatureIds", () => { + it("should return empty object when teamIds is empty", async () => { + const result = await repository.findByTeamIdsAndFeatureIds([], ["feature-1"]); + + expect(result).toEqual({}); + }); + + it("should return empty object when featureIds is empty", async () => { + const result = await repository.findByTeamIdsAndFeatureIds([1, 2], []); + + expect(result).toEqual({}); + }); + + it("should return features grouped by featureId and teamId", async () => { + const cachedFeature1 = { + teamId: 1, + featureId: "feature-1", + enabled: true, + assignedBy: "admin", + updatedAt: new Date("2024-01-01"), + }; + const cachedFeature2 = { + teamId: 2, + featureId: "feature-1", + enabled: false, + assignedBy: "admin", + updatedAt: new Date("2024-01-01"), + }; + + vi.mocked(mockRedis.get).mockResolvedValueOnce(cachedFeature1).mockResolvedValueOnce(cachedFeature2); + + const result = await repository.findByTeamIdsAndFeatureIds([1, 2], ["feature-1"]); + + expect(result).toEqual({ + "feature-1": { + 1: cachedFeature1, + 2: cachedFeature2, + }, + }); + }); + }); + + describe("upsert", () => { + it("should upsert team feature and invalidate cache", async () => { + const upsertedFeature = { + teamId: 1, + featureId: "feature-1", + enabled: true, + assignedBy: "admin", + updatedAt: new Date("2024-01-01"), + }; + vi.mocked(mockPrisma.teamFeatures.upsert).mockResolvedValue(upsertedFeature); + vi.mocked(mockRedis.del).mockResolvedValue(1); + + const result = await repository.upsert(1, "feature-1", true, "admin"); + + expect(result).toEqual(upsertedFeature); + expect(mockPrisma.teamFeatures.upsert).toHaveBeenCalledWith({ + where: { + teamId_featureId: { + teamId: 1, + featureId: "feature-1", + }, + }, + create: { + teamId: 1, + featureId: "feature-1", + enabled: true, + assignedBy: "admin", + }, + update: { + enabled: true, + assignedBy: "admin", + }, + }); + expect(mockRedis.del).toHaveBeenCalledWith("features:team:1:feature-1"); + }); + }); + + describe("delete", () => { + it("should delete team feature and invalidate cache", async () => { + vi.mocked(mockPrisma.teamFeatures.delete).mockResolvedValue({ + teamId: 1, + featureId: "feature-1", + enabled: true, + assignedBy: "admin", + updatedAt: new Date("2024-01-01"), + }); + vi.mocked(mockRedis.del).mockResolvedValue(1); + + await repository.delete(1, "feature-1"); + + expect(mockPrisma.teamFeatures.delete).toHaveBeenCalledWith({ + where: { + teamId_featureId: { + teamId: 1, + featureId: "feature-1", + }, + }, + }); + expect(mockRedis.del).toHaveBeenCalledWith("features:team:1:feature-1"); + }); + }); + + describe("findAutoOptInByTeamId", () => { + it("should return cached auto opt-in value on cache hit", async () => { + vi.mocked(mockRedis.get).mockResolvedValue(true); + + const result = await repository.findAutoOptInByTeamId(1); + + expect(result).toBe(true); + expect(mockRedis.get).toHaveBeenCalledWith("features:team:autoOptIn:1"); + expect(mockPrisma.team.findUnique).not.toHaveBeenCalled(); + }); + + it("should fetch from database and cache on cache miss", async () => { + vi.mocked(mockRedis.get).mockResolvedValue(null); + vi.mocked(mockPrisma.team.findUnique).mockResolvedValue({ id: 1, autoOptInFeatures: true }); + vi.mocked(mockRedis.set).mockResolvedValue("OK"); + + const result = await repository.findAutoOptInByTeamId(1); + + expect(result).toBe(true); + expect(mockPrisma.team.findUnique).toHaveBeenCalledWith({ + where: { id: 1 }, + select: { autoOptInFeatures: true }, + }); + expect(mockRedis.set).toHaveBeenCalled(); + }); + + it("should return false when team not found", async () => { + vi.mocked(mockRedis.get).mockResolvedValue(null); + vi.mocked(mockPrisma.team.findUnique).mockResolvedValue(null); + + const result = await repository.findAutoOptInByTeamId(1); + + expect(result).toBe(false); + }); + }); + + describe("findAutoOptInByTeamIds", () => { + it("should return empty object when teamIds is empty", async () => { + const result = await repository.findAutoOptInByTeamIds([]); + + expect(result).toEqual({}); + }); + + it("should return auto opt-in values for multiple teams", async () => { + vi.mocked(mockRedis.get).mockResolvedValueOnce(true).mockResolvedValueOnce(false); + + const result = await repository.findAutoOptInByTeamIds([1, 2]); + + expect(result).toEqual({ + 1: true, + 2: false, + }); + }); + }); +}); diff --git a/packages/features/flags/repositories/schemas.ts b/packages/features/flags/repositories/schemas.ts new file mode 100644 index 0000000000..c484335bd2 --- /dev/null +++ b/packages/features/flags/repositories/schemas.ts @@ -0,0 +1,63 @@ +import type { ZodObject } from "zod"; +import { z } from "zod"; + +const dateSchema: z.ZodDate = z.coerce.date(); +const optionalDateSchema: z.ZodNullable = z.coerce.date().nullable(); + +export const featureSchema: ZodObject<{ + slug: z.ZodString; + enabled: z.ZodBoolean; + description: z.ZodNullable; + type: z.ZodNullable>; + stale: z.ZodNullable; + lastUsedAt: z.ZodNullable; + createdAt: z.ZodNullable; + updatedAt: z.ZodNullable; + updatedBy: z.ZodNullable; +}> = z.object({ + slug: z.string(), + enabled: z.boolean(), + description: z.string().nullable(), + type: z.enum(["RELEASE", "EXPERIMENT", "OPERATIONAL", "PERMISSION", "KILL_SWITCH"]).nullable(), + stale: z.boolean().nullable(), + lastUsedAt: optionalDateSchema, + createdAt: optionalDateSchema, + updatedAt: optionalDateSchema, + updatedBy: z.number().nullable(), +}); + +export type CachedFeature = z.infer; + +export const featureArraySchema = z.array(featureSchema); + +export const appFlagsSchema = z.record(z.string(), z.boolean()); + +export type CachedAppFlags = z.infer; + +export const teamFeaturesMapSchema = z.record(z.string(), z.boolean()); + +export type CachedTeamFeaturesMap = z.infer; + +export const teamFeaturesSchema = z.object({ + teamId: z.number(), + featureId: z.string(), + enabled: z.boolean(), + assignedBy: z.string(), + assignedAt: dateSchema, + updatedAt: dateSchema, +}); + +export type CachedTeamFeatures = z.infer; + +export const userFeaturesSchema = z.object({ + userId: z.number(), + featureId: z.string(), + enabled: z.boolean(), + assignedBy: z.string(), + assignedAt: dateSchema, + updatedAt: dateSchema, +}); + +export type CachedUserFeatures = z.infer; + +export const booleanSchema = z.boolean(); diff --git a/packages/features/tsconfig.json b/packages/features/tsconfig.json index 1d4fd00cb2..9d9612e3d8 100644 --- a/packages/features/tsconfig.json +++ b/packages/features/tsconfig.json @@ -8,7 +8,8 @@ "~/*": ["/*"] }, "resolveJsonModule": true, - "esModuleInterop": true + "esModuleInterop": true, + "experimentalDecorators": true }, "include": [".", "../types/next-auth.d.ts", "../types/tanstack-table.d.ts"], "exclude": ["dist", "build", "**/node_modules/**", "**/*.test.*", "**/__mocks__/*", "**/__tests__/*"] diff --git a/packages/lib/dto/FeatureDto.ts b/packages/lib/dto/FeatureDto.ts new file mode 100644 index 0000000000..6f833d3233 --- /dev/null +++ b/packages/lib/dto/FeatureDto.ts @@ -0,0 +1,28 @@ +import type { ZodArray, ZodObject, ZodTypeAny } from "zod"; +import { z } from "zod"; + +export const FeatureDtoSchema: ZodObject<{ + slug: ZodTypeAny; + enabled: ZodTypeAny; + description: ZodTypeAny; + type: ZodTypeAny; + stale: ZodTypeAny; + lastUsedAt: ZodTypeAny; + createdAt: ZodTypeAny; + updatedAt: ZodTypeAny; + updatedBy: ZodTypeAny; +}> = z.object({ + slug: z.string(), + enabled: z.boolean(), + description: z.string().nullable(), + type: z.enum(["RELEASE", "EXPERIMENT", "OPERATIONAL", "PERMISSION", "KILL_SWITCH"]).nullable(), + stale: z.boolean().nullable(), + lastUsedAt: z.coerce.date().nullable(), + createdAt: z.coerce.date().nullable(), + updatedAt: z.coerce.date().nullable(), + updatedBy: z.number().nullable(), +}); + +export type FeatureDto = z.infer; + +export const FeatureDtoArraySchema: ZodArray = z.array(FeatureDtoSchema); diff --git a/packages/lib/dto/TeamFeaturesDto.ts b/packages/lib/dto/TeamFeaturesDto.ts new file mode 100644 index 0000000000..acab002541 --- /dev/null +++ b/packages/lib/dto/TeamFeaturesDto.ts @@ -0,0 +1,18 @@ +import type { ZodObject, ZodTypeAny } from "zod"; +import { z } from "zod"; + +export const TeamFeaturesDtoSchema: ZodObject<{ + teamId: ZodTypeAny; + featureId: ZodTypeAny; + enabled: ZodTypeAny; + assignedBy: ZodTypeAny; + updatedAt: ZodTypeAny; +}> = z.object({ + teamId: z.number(), + featureId: z.string(), + enabled: z.boolean(), + assignedBy: z.string(), + updatedAt: z.coerce.date(), +}); + +export type TeamFeaturesDto = z.infer; diff --git a/packages/lib/dto/UserFeaturesDto.ts b/packages/lib/dto/UserFeaturesDto.ts new file mode 100644 index 0000000000..b531f1a6ce --- /dev/null +++ b/packages/lib/dto/UserFeaturesDto.ts @@ -0,0 +1,18 @@ +import type { ZodObject, ZodTypeAny } from "zod"; +import { z } from "zod"; + +export const UserFeaturesDtoSchema: ZodObject<{ + userId: ZodTypeAny; + featureId: ZodTypeAny; + enabled: ZodTypeAny; + assignedBy: ZodTypeAny; + updatedAt: ZodTypeAny; +}> = z.object({ + userId: z.number(), + featureId: z.string(), + enabled: z.boolean(), + assignedBy: z.string(), + updatedAt: z.coerce.date(), +}); + +export type UserFeaturesDto = z.infer; diff --git a/packages/trpc/server/routers/viewer/featureOptIn/_router.ts b/packages/trpc/server/routers/viewer/featureOptIn/_router.ts index 884c3c6cef..5b685c8af4 100644 --- a/packages/trpc/server/routers/viewer/featureOptIn/_router.ts +++ b/packages/trpc/server/routers/viewer/featureOptIn/_router.ts @@ -1,13 +1,13 @@ import { getFeatureOptInService } from "@calcom/features/di/containers/FeatureOptInService"; +import { getTeamFeatureRepository } from "@calcom/features/di/containers/TeamFeatureRepository"; +import { getUserFeatureRepository } from "@calcom/features/di/containers/UserFeatureRepository"; import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import { isOptInFeature } from "@calcom/features/feature-opt-in/config"; -import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; import { prisma } from "@calcom/prisma"; import { TRPCError } from "@trpc/server"; import type { ZodEnum } from "zod"; import { z } from "zod"; - import authedProcedure from "../../../procedures/authedProcedure"; import { createOrgPbacProcedure, createTeamPbacProcedure } from "../../../procedures/pbacProcedures"; import { router } from "../../../trpc"; @@ -19,7 +19,8 @@ const featureStateSchema: ZodEnum<["enabled", "disabled", "inherit"]> = z.enum([ ]); const featureOptInService: ReturnType = getFeatureOptInService(); -const featuresRepository: FeaturesRepository = new FeaturesRepository(prisma); +const teamFeatureRepository: ReturnType = getTeamFeatureRepository(); +const userFeatureRepository: ReturnType = getUserFeatureRepository(); const teamRepository: TeamRepository = new TeamRepository(prisma); const membershipRepository: MembershipRepository = new MembershipRepository(prisma); @@ -185,7 +186,7 @@ export const featureOptInRouter = router({ * Get user's auto opt-in preference. */ getUserAutoOptIn: authedProcedure.query(async ({ ctx }) => { - const autoOptIn = await featuresRepository.getUserAutoOptIn(ctx.user.id); + const autoOptIn = await userFeatureRepository.findAutoOptInByUserId(ctx.user.id); return { autoOptIn }; }), @@ -199,7 +200,7 @@ export const featureOptInRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - await featuresRepository.setUserAutoOptIn(ctx.user.id, input.autoOptIn); + await userFeatureRepository.setAutoOptIn(ctx.user.id, input.autoOptIn); return { success: true }; }), @@ -207,7 +208,7 @@ export const featureOptInRouter = router({ * Get team's auto opt-in preference (requires team admin). */ getTeamAutoOptIn: createTeamPbacProcedure("featureOptIn.read").query(async ({ input }) => { - const result = await featuresRepository.getTeamsAutoOptIn([input.teamId]); + const result = await teamFeatureRepository.findAutoOptInByTeamIds([input.teamId]); return { autoOptIn: result[input.teamId] ?? false }; }), @@ -221,7 +222,7 @@ export const featureOptInRouter = router({ }) ) .mutation(async ({ input }) => { - await featuresRepository.setTeamAutoOptIn(input.teamId, input.autoOptIn); + await teamFeatureRepository.setAutoOptIn(input.teamId, input.autoOptIn); return { success: true }; }), @@ -229,7 +230,7 @@ export const featureOptInRouter = router({ * Get organization's auto opt-in preference (requires org admin). */ getOrganizationAutoOptIn: createOrgPbacProcedure("featureOptIn.read").query(async ({ ctx }) => { - const result = await featuresRepository.getTeamsAutoOptIn([ctx.organizationId]); + const result = await teamFeatureRepository.findAutoOptInByTeamIds([ctx.organizationId]); return { autoOptIn: result[ctx.organizationId] ?? false }; }), @@ -243,7 +244,7 @@ export const featureOptInRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - await featuresRepository.setTeamAutoOptIn(ctx.organizationId, input.autoOptIn); + await teamFeatureRepository.setAutoOptIn(ctx.organizationId, input.autoOptIn); return { success: true }; }), });