feat(flags): add @Memoize and @Unmemoize decorators for declarative caching (#27063)

* feat(flags): add @Memoize and @Unmemoize decorators for declarative caching

- Add @Memoize decorator for caching method results with Zod validation
- Add @Unmemoize decorator for cache invalidation on mutations
- Create UserFeatureRepository using decorators as example implementation
- Add comprehensive tests with 80%+ coverage (19 tests)
- Add DI module and tokens for UserFeatureRepository
- Enable experimentalDecorators in packages/features/tsconfig.json

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(cache): use lazy Redis lookup in decorators

- Decorators now access Redis via getRedisService() instead of this.redis
- UserFeatureRepository no longer has redis property or direct redis calls
- findByUserIdAndFeatureIds now uses decorated findByUserIdAndFeatureId
- DI module simplified to only pass prisma dependency
- Tests updated to call setRedisService() in beforeEach

This ensures the repository only knows about Prisma, with all caching
handled transparently by the decorators.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat(flags): add FeatureRepository and TeamFeatureRepository with decorator-based caching

- Add FeatureRepository with @Memoize decorators for findAll, findBySlug, getFeatureFlagMap
- Add TeamFeatureRepository with @Memoize/@Unmemoize decorators for all CRUD operations
- Add comprehensive Zod schemas for Feature, TeamFeatures, and AppFlags validation
- Add DI modules and tokens for both repositories
- Add comprehensive test coverage (33 tests) for both repositories
- Maintain backward compatibility with existing FEATURES_REPOSITORY tokens

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(cache): use DI container pattern for Redis service

- Create packages/features/di/containers/Redis.ts with getRedisService()
- Update Memoize and Unmemoize decorators to import from DI container
- Remove setRedisService() and IRedisService from types.ts exports
- Update all tests to mock the container's getRedisService
- Fix import ordering issues from biome

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(cache): remove barrel import and add root-level cache export

- Remove packages/features/cache/decorators/index.ts barrel file
- Add packages/features/cache/index.ts as root-level export for cache feature
- Update repository imports to use direct imports from source files
- Follows the pattern: avoid barrel imports at nested levels, keep at feature root

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(flags): minimize repository methods and address PR feedback

- Remove unnecessary methods from FeatureRepository, TeamFeatureRepository, and UserFeatureRepository
- Keep only methods needed by FeatureOptInService
- Update imports to use @calcom/features/cache public API instead of relative paths
- Handle redis.del() errors gracefully in Unmemoize decorator
- Update tests to match simplified repositories

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(cache): use Promise.allSettled for cache invalidation

Cleaner approach than Promise.all with individual .catch() handlers

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat(flags): add setAutoOptIn methods and replace featuresRepository usage in _router.ts

- Add setAutoOptIn method to TeamFeatureRepository with @Unmemoize decorator
- Add setAutoOptIn method to UserFeatureRepository with @Unmemoize decorator
- Replace featuresRepository usage in featureOptIn/_router.ts with new repositories
- TeamFeatureRepository.setAutoOptIn unmemoizes KEY.autoOptInByTeamId(teamId)
- UserFeatureRepository.setAutoOptIn unmemoizes KEY.autoOptInByUserId(userId)

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(flags): use DI containers for TeamFeatureRepository and UserFeatureRepository

- Create TeamFeatureRepository.ts container with getTeamFeatureRepository()
- Create UserFeatureRepository.ts container with getUserFeatureRepository()
- Update _router.ts to use DI containers instead of direct instantiation

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(feature-opt-in): use DI containers for FeatureOptInService dependencies

- Update FeatureOptInService to use IFeatureOptInServiceDeps with 3 repositories
- Add helper functions teamFeatureToState() and userFeatureToState()
- Update DI module to use depsMap pattern with featureRepo, teamFeatureRepo, userFeatureRepo
- Create FeatureRepository container file
- Replace all FeaturesRepository method calls with new repository methods

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* test(feature-opt-in): update FeatureOptInService tests for new IFeatureOptInServiceDeps interface

- Update mock structure to use featureRepo, teamFeatureRepo, userFeatureRepo
- Add helper functions createMockTeamFeature and createMockUserFeature
- Update all test cases to use new repository method names
- Add explicit types to satisfy biome lint rules

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(flags): use DTOs at repository boundaries to prevent Prisma type leakage

- Create FeatureDto, TeamFeaturesDto, UserFeaturesDto in packages/lib/dto/
- Update repository interfaces to return DTOs instead of Prisma types
- Add toDto() transformation methods in repository implementations
- Update FeatureOptInService to use DTOs instead of Prisma types
- Follow data-dto-boundaries.md guidelines for architectural boundaries

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix(flags): update TeamFeatureRepository tests to match DTO structure

Remove assignedAt from test mock data since TeamFeaturesDto only includes:
teamId, featureId, enabled, assignedBy, and updatedAt

Co-Authored-By: unknown <>

* fix(cache): make Redis failures non-blocking in @Memoize decorator

Wrap Redis get and set operations in try-catch blocks to ensure
Redis failures don't break the application flow. If cache read fails,
proceed to fetch from source. If cache write fails, silently ignore
and return the result.

Co-Authored-By: unknown <>

* fix(cache): add logging for Redis failures and remove barrel import

- Add warning logs for Redis failures in @Memoize and @Unmemoize decorators
- Remove packages/lib/dto/index.ts barrel import file
- Update all imports to use direct file paths instead of barrel imports

Co-Authored-By: unknown <>

* fix(cache): sanitize log messages to avoid exposing sensitive data

- Remove cacheKey from log messages (may contain PII like user/team IDs)
- Log only error.message instead of raw error objects
- Addresses Cubic AI feedback (confidence 9/10)

Co-Authored-By: unknown <>

* sanitize log

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei@cal.com>
This commit is contained in:
Eunjae Lee
2026-01-22 16:26:27 +01:00
committed by GitHub
co-authored by unknown Devin AI Volnei Munhoz
parent a2a4ca41ec
commit aa8804e498
29 changed files with 2080 additions and 166 deletions
+72
View File
@@ -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 <T>(
_target: object,
_propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<T>
): TypedPropertyDescriptor<T> => {
const originalMethod = descriptor.value as ((...args: unknown[]) => Promise<unknown>) | 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<unknown> {
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<unknown>(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;
};
}
+43
View File
@@ -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 <T>(
_target: object,
_propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<T>
): TypedPropertyDescriptor<T> => {
const originalMethod = descriptor.value as ((...args: unknown[]) => Promise<unknown>) | 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<unknown> {
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;
};
}
@@ -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<string> {
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();
});
});
@@ -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<void> {
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<void> {
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<string> {
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<null> {
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<void> {
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();
});
});
+3
View File
@@ -0,0 +1,3 @@
const DEFAULT_TTL_MS: number = 5 * 60 * 1000; // 5 minutes
export { DEFAULT_TTL_MS };
+3
View File
@@ -0,0 +1,3 @@
export { Memoize } from "./decorators/Memoize";
export { DEFAULT_TTL_MS } from "./decorators/types";
export { Unmemoize } from "./decorators/Unmemoize";
@@ -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<IFeatureRepository>(featureRepositoryModuleLoader.token);
}
+11
View File
@@ -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<IRedisService>(DI_TOKENS.REDIS_CLIENT);
}
@@ -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<ITeamFeatureRepository>(teamFeatureRepositoryModuleLoader.token);
}
@@ -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<IUserFeatureRepository>(userFeatureRepositoryModuleLoader.token);
}
@@ -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<typeof bindModuleToClassOnToken> = bindModuleToClassOnToken({
module: thisModule,
moduleToken,
token,
classs: FeatureOptInService,
dep: featuresRepositoryModuleLoader,
depsMap: {
featureRepo: featureRepositoryModuleLoader,
teamFeatureRepo: teamFeatureRepositoryModuleLoader,
userFeatureRepo: userFeatureRepositoryModuleLoader,
},
});
export const moduleLoader: ModuleLoader = {
@@ -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<typeof vi.fn>;
getTeamsFeatureStates: ReturnType<typeof vi.fn>;
setUserFeatureState: ReturnType<typeof vi.fn>;
setTeamFeatureState: ReturnType<typeof vi.fn>;
// 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<typeof vi.fn>;
};
let mockTeamFeatureRepo: {
findByTeamIdAndFeatureId: ReturnType<typeof vi.fn>;
findByTeamIdsAndFeatureIds: ReturnType<typeof vi.fn>;
upsert: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
findAutoOptInByTeamId: ReturnType<typeof vi.fn>;
findAutoOptInByTeamIds: ReturnType<typeof vi.fn>;
setAutoOptIn: ReturnType<typeof vi.fn>;
};
let mockUserFeatureRepo: {
findByUserIdAndFeatureId: ReturnType<typeof vi.fn>;
findByUserIdAndFeatureIds: ReturnType<typeof vi.fn>;
upsert: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
findAutoOptInByUserId: ReturnType<typeof vi.fn>;
setAutoOptIn: ReturnType<typeof vi.fn>;
};
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);
}
},
@@ -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<number, FeatureState>): FeatureState {
function getOrgState(orgId: number | null, teamStatesById: Record<number, TeamFeaturesDto>): 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<string, Record<number, FeatureState>>,
teamStates: Partial<Record<FeatureId, Record<number, TeamFeaturesDto>>>,
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<string, boolean>,
allTeamStates: Record<string, Record<number, FeatureState>>,
userStates: Record<string, FeatureState>,
allTeamStates: Partial<Record<FeatureId, Record<number, TeamFeaturesDto>>>,
userStates: Partial<Record<FeatureId, UserFeaturesDto>>,
teamsAutoOptIn: Record<number, boolean>,
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";
@@ -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<UserFeatures | null> {
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<UserFeatures> {
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<TArgs extends unknown[]> {
key: (...args: TArgs) => string;
ttl?: number;
schema?: ZodSchema;
}
function Memoize<TArgs extends unknown[]>(config: MemoizeConfig<TArgs>) {
return function <T extends { redis: IRedisService }>(
target: (this: T, ...args: TArgs) => Promise<unknown>,
context: ClassMethodDecoratorContext
) {
return async function (this: T, ...args: TArgs) {
const cacheKey = config.key(...args);
const cached = await this.redis.get<unknown>(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<TArgs extends unknown[]> {
keys: (...args: TArgs) => string[];
}
function Unmemoize<TArgs extends unknown[]>(config: UnmemoizeConfig<TArgs>) {
return function <T extends { redis: IRedisService }>(
target: (this: T, ...args: TArgs) => Promise<unknown>,
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<UserFeatures[]> {
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<UserFeatures | null> {
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<boolean> {
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<UserFeatures> {
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<void> {
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<void> {
await this.prisma.user.update({
where: { id: userId },
data: { autoOptInFeatures: enabled },
});
}
// Methods without caching (complex queries)
async checkIfUserBelongsToTeamWithFeature(userId: number, slug: string): Promise<boolean> {
// ... existing implementation
}
async checkIfUserBelongsToTeamWithFeatureNonHierarchical(userId: number, slug: string): Promise<boolean> {
// ... 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
@@ -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<typeof import("@calcom/features/di/di").createContainer>): void => {
container.load(moduleToken, thisModule);
prismaModuleLoader.loadModule(container);
};
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
export type { FeatureRepository };
@@ -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<typeof import("@calcom/features/di/di").createContainer>): void => {
container.load(moduleToken, thisModule);
prismaModuleLoader.loadModule(container);
};
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
export type { TeamFeatureRepository };
@@ -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<typeof import("@calcom/features/di/di").createContainer>): void => {
container.load(moduleToken, thisModule);
prismaModuleLoader.loadModule(container);
};
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
export type { UserFeatureRepository };
+19 -1
View File
@@ -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"),
};
@@ -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<FeatureDto[]>;
}
export class FeatureRepository implements IFeatureRepository {
private prisma: PrismaClient;
constructor(prisma: PrismaClient) {
this.prisma = prisma;
}
@Memoize({
key: () => KEY.all(),
schema: FeatureDtoArraySchema,
})
async findAll(): Promise<FeatureDto[]> {
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,
}));
}
}
@@ -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<TeamFeaturesDto | null>;
findByTeamIdsAndFeatureIds(
teamIds: number[],
featureIds: FeatureId[]
): Promise<Partial<Record<FeatureId, Record<number, TeamFeaturesDto>>>>;
upsert(
teamId: number,
featureId: FeatureId,
enabled: boolean,
assignedBy: string
): Promise<TeamFeaturesDto>;
delete(teamId: number, featureId: FeatureId): Promise<void>;
findAutoOptInByTeamId(teamId: number): Promise<boolean>;
findAutoOptInByTeamIds(teamIds: number[]): Promise<Record<number, boolean>>;
setAutoOptIn(teamId: number, enabled: boolean): Promise<void>;
}
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<TeamFeaturesDto | null> {
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<Partial<Record<FeatureId, Record<number, TeamFeaturesDto>>>> {
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<Record<FeatureId, Record<number, TeamFeaturesDto>>> = {};
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<TeamFeaturesDto> {
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<void> {
await this.prisma.teamFeatures.delete({
where: {
teamId_featureId: {
teamId,
featureId,
},
},
});
}
@Memoize({
key: (teamId: number) => KEY.autoOptInByTeamId(teamId),
schema: booleanSchema,
})
async findAutoOptInByTeamId(teamId: number): Promise<boolean> {
const team = await this.prisma.team.findUnique({
where: { id: teamId },
select: { autoOptInFeatures: true },
});
return team?.autoOptInFeatures ?? false;
}
async findAutoOptInByTeamIds(teamIds: number[]): Promise<Record<number, boolean>> {
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<number, boolean> = {};
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<void> {
await this.prisma.team.update({
where: { id: teamId },
data: { autoOptInFeatures: enabled },
});
}
}
@@ -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<UserFeaturesDto | null>;
findByUserIdAndFeatureIds(
userId: number,
featureIds: FeatureId[]
): Promise<Partial<Record<FeatureId, UserFeaturesDto>>>;
upsert(
userId: number,
featureId: FeatureId,
enabled: boolean,
assignedBy: string
): Promise<UserFeaturesDto>;
delete(userId: number, featureId: FeatureId): Promise<void>;
findAutoOptInByUserId(userId: number): Promise<boolean>;
setAutoOptIn(userId: number, enabled: boolean): Promise<void>;
}
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<UserFeaturesDto | null> {
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<Partial<Record<FeatureId, UserFeaturesDto>>> {
const results = await Promise.all(
featureIds.map(async (featureId) => {
const userFeature = await this.findByUserIdAndFeatureId(userId, featureId);
return { featureId, userFeature };
})
);
const result: Partial<Record<FeatureId, UserFeaturesDto>> = {};
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<UserFeaturesDto> {
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<void> {
await this.prisma.userFeatures.delete({
where: {
userId_featureId: {
userId,
featureId,
},
},
});
}
@Memoize({
key: (userId: number) => KEY.autoOptInByUserId(userId),
schema: booleanSchema,
})
async findAutoOptInByUserId(userId: number): Promise<boolean> {
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<void> {
await this.prisma.user.update({
where: { id: userId },
data: { autoOptInFeatures: enabled },
});
}
}
@@ -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<typeof vi.fn>;
};
}
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();
});
});
});
@@ -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<typeof vi.fn>;
upsert: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
team: {
findUnique: ReturnType<typeof vi.fn>;
};
}
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,
});
});
});
});
@@ -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.ZodDate> = z.coerce.date().nullable();
export const featureSchema: ZodObject<{
slug: z.ZodString;
enabled: z.ZodBoolean;
description: z.ZodNullable<z.ZodString>;
type: z.ZodNullable<z.ZodEnum<["RELEASE", "EXPERIMENT", "OPERATIONAL", "PERMISSION", "KILL_SWITCH"]>>;
stale: z.ZodNullable<z.ZodBoolean>;
lastUsedAt: z.ZodNullable<z.ZodDate>;
createdAt: z.ZodNullable<z.ZodDate>;
updatedAt: z.ZodNullable<z.ZodDate>;
updatedBy: z.ZodNullable<z.ZodNumber>;
}> = 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<typeof featureSchema>;
export const featureArraySchema = z.array(featureSchema);
export const appFlagsSchema = z.record(z.string(), z.boolean());
export type CachedAppFlags = z.infer<typeof appFlagsSchema>;
export const teamFeaturesMapSchema = z.record(z.string(), z.boolean());
export type CachedTeamFeaturesMap = z.infer<typeof teamFeaturesMapSchema>;
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<typeof teamFeaturesSchema>;
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<typeof userFeaturesSchema>;
export const booleanSchema = z.boolean();
+2 -1
View File
@@ -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__/*"]
+28
View File
@@ -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<typeof FeatureDtoSchema>;
export const FeatureDtoArraySchema: ZodArray<typeof FeatureDtoSchema> = z.array(FeatureDtoSchema);
+18
View File
@@ -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<typeof TeamFeaturesDtoSchema>;
+18
View File
@@ -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<typeof UserFeaturesDtoSchema>;
@@ -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<typeof getFeatureOptInService> = getFeatureOptInService();
const featuresRepository: FeaturesRepository = new FeaturesRepository(prisma);
const teamFeatureRepository: ReturnType<typeof getTeamFeatureRepository> = getTeamFeatureRepository();
const userFeatureRepository: ReturnType<typeof getUserFeatureRepository> = 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 };
}),
});