refactor: split flag repositories into Prisma and Cached layers (#27186)

* refactor: split flag repositories into Prisma and Cached layers

- Rename FeatureRepository to PrismaFeatureRepository (raw DB access)
- Rename TeamFeatureRepository to PrismaTeamFeatureRepository (raw DB access)
- Rename UserFeatureRepository to PrismaUserFeatureRepository (raw DB access)
- Create CachedFeatureRepository with @Memoize wrapping PrismaFeatureRepository
- Create CachedTeamFeatureRepository with @Memoize/@Unmemoize wrapping PrismaTeamFeatureRepository
- Create CachedUserFeatureRepository with @Memoize/@Unmemoize wrapping PrismaUserFeatureRepository
- Update DI tokens, modules, and containers for all 6 repositories
- Update imports in FeatureOptInService and related modules
- Update tests to use new repository structure

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

* refactor: simplify @Memoize key patterns and delegate batch methods to Prisma

- Use direct function references for @Memoize key (e.g., KEY.all instead of () => KEY.all())
- Simplify batch methods in Cached repositories to delegate to Prisma repository
- Update tests to reflect the new delegation pattern

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

* fix: add orderBy to TeamRepository.findAllByParentId for deterministic results

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

* test: update TeamRepository test to expect orderBy in findAllByParentId

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

* refactor: cleanup features repository and add specialized repository methods (#27195)

* refactor: cleanup features repository and add findBySlug, update methods

- Remove unused methods from FeaturesRepository (keep getTeamsWithFeatureEnabled)
- Add findAll(), findBySlug(), update() to IFeatureRepository interface
- Add findAll() with caching to CachedFeatureRepository
- Add findBySlug() with caching to CachedFeatureRepository
- Add update() with Unmemoize to CachedFeatureRepository
- Add checkIfFeatureIsEnabledGlobally() to CachedFeatureRepository
- Update toggleFeatureFlag.handler.ts to use repository instead of raw Prisma
- Add comprehensive unit tests for all new methods

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

* fix: update updatedAt timestamp in feature update method

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

* refactor: move feature check methods to specialized repositories

- Replace getUserFeaturesStatus with two checkIfUserHasFeature calls in bookings page
- Move checkIfTeamHasFeature to PrismaTeamFeatureRepository with pass-through in CachedTeamFeatureRepository
- Move checkIfUserHasFeature and checkIfUserHasFeatureNonHierarchical to PrismaUserFeatureRepository with pass-throughs in CachedUserFeatureRepository
- Add getEnabledFeatures to PrismaTeamFeatureRepository with caching in CachedTeamFeatureRepository
- Keep FeaturesRepository methods as pass-throughs for backward compatibility
- Update test to expect updatedAt in feature update

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

* refactor: remove getUserFeaturesStatus and unused methods from FeaturesRepository

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

* restore comment

* fix: invalidate all-features cache on update and enabledFeatures cache on upsert/delete

- CachedFeatureRepository: Add KEY.all() to @Unmemoize keys in update() to prevent stale findAll() results
- CachedTeamFeatureRepository: Add KEY.enabledFeatures(teamId) to @Unmemoize keys in upsert() and delete() to prevent stale getEnabledFeatures() results

Co-Authored-By: unknown <>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: add CachedUserFeatureRepository tests

Add comprehensive tests for CachedUserFeatureRepository covering:
- findByUserIdAndFeatureId (cache hit, cache miss, not found)
- findByUserIdAndFeatureIds (empty input, multiple features)
- upsert (with cache invalidation)
- delete (with cache invalidation)
- findAutoOptInByUserId (cache hit, cache miss, not found)
- setAutoOptIn (with cache invalidation)

Co-Authored-By: unknown <>

* test: remove tests for methods removed from FeaturesRepository

Remove integration tests for methods that were intentionally removed:
- getUserFeatureStates
- getTeamsFeatureStates
- getUserAutoOptIn
- getTeamsAutoOptIn
- setUserAutoOptIn
- setTeamAutoOptIn

Co-Authored-By: unknown <>

* avoid N+1 query

* refactor: add select clauses to PrismaFeatureRepository queries

- Add explicit select clauses to findAll, findBySlug, and update methods
- Only fetch fields needed for FeatureDto (slug, enabled, description, type, stale, lastUsedAt, createdAt, updatedAt, updatedBy)
- Update tests to expect select clauses
- Fix UserFeatureRepository test to use findMany mock

Co-Authored-By: unknown <>

* fix bad conflict resolved

* use userId

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Eunjae Lee
2026-02-02 14:26:23 +01:00
committed by GitHub
co-authored by unknown Devin AI
parent 8c123ece25
commit 29ec2b9cb6
30 changed files with 1307 additions and 1005 deletions
@@ -1,6 +1,6 @@
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getFeatureOptInService } from "@calcom/features/di/containers/FeatureOptInService";
import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository";
import { getUserFeatureRepository } from "@calcom/features/di/containers/UserFeatureRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -40,6 +40,7 @@ const Page = async ({ params }: PageProps) => {
const userId = session.user.id;
const permissionService = new PermissionCheckService();
const userFeatureRepository = getUserFeatureRepository();
const teamIdsWithPermission = await permissionService.getTeamIdsWithPermission({
userId,
@@ -52,11 +53,10 @@ const Page = async ({ params }: PageProps) => {
// This variable is primarily for UI purposes.
const canReadOthersBookings = teamIdsWithPermission.length > 0;
const featuresRepository = getFeaturesRepository();
const featureOptInService = getFeatureOptInService();
const [featureFlags, featureStates] = await Promise.all([
featuresRepository.getUserFeaturesStatus(userId, ["booking-audit"]),
const [bookingAuditEnabled, featureStates] = await Promise.all([
userFeatureRepository.checkIfUserHasFeature(userId, "booking-audit"),
featureOptInService.resolveFeatureStates({
userId,
featureIds: ["bookings-v3"],
@@ -64,7 +64,6 @@ const Page = async ({ params }: PageProps) => {
]);
const bookingsV3Enabled = featureStates["bookings-v3"]?.effectiveEnabled ?? false;
const bookingAuditEnabled = featureFlags["booking-audit"] ?? false;
return (
<ShellMainAppDir
@@ -1,10 +1,10 @@
import type { IFeatureRepository } from "@calcom/features/flags/repositories/FeatureRepository";
import { moduleLoader as featureRepositoryModuleLoader } from "../../flags/di/FeatureRepository.module";
import type { IFeatureRepository } from "@calcom/features/flags/repositories/PrismaFeatureRepository";
import { moduleLoader as cachedFeatureRepositoryModuleLoader } from "../../flags/di/CachedFeatureRepository.module";
import { type Container, createContainer } from "../di";
const featureRepositoryContainer: Container = createContainer();
export function getFeatureRepository(): IFeatureRepository {
featureRepositoryModuleLoader.loadModule(featureRepositoryContainer);
return featureRepositoryContainer.get<IFeatureRepository>(featureRepositoryModuleLoader.token);
cachedFeatureRepositoryModuleLoader.loadModule(featureRepositoryContainer);
return featureRepositoryContainer.get<IFeatureRepository>(cachedFeatureRepositoryModuleLoader.token);
}
@@ -1,10 +1,12 @@
import type { ITeamFeatureRepository } from "@calcom/features/flags/repositories/TeamFeatureRepository";
import { moduleLoader as teamFeatureRepositoryModuleLoader } from "../../flags/di/TeamFeatureRepository.module";
import type { ITeamFeatureRepository } from "@calcom/features/flags/repositories/PrismaTeamFeatureRepository";
import { moduleLoader as cachedTeamFeatureRepositoryModuleLoader } from "../../flags/di/CachedTeamFeatureRepository.module";
import { createContainer } from "../di";
const teamFeatureRepositoryContainer = createContainer();
export function getTeamFeatureRepository(): ITeamFeatureRepository {
teamFeatureRepositoryModuleLoader.loadModule(teamFeatureRepositoryContainer);
return teamFeatureRepositoryContainer.get<ITeamFeatureRepository>(teamFeatureRepositoryModuleLoader.token);
cachedTeamFeatureRepositoryModuleLoader.loadModule(teamFeatureRepositoryContainer);
return teamFeatureRepositoryContainer.get<ITeamFeatureRepository>(
cachedTeamFeatureRepositoryModuleLoader.token
);
}
@@ -1,10 +1,12 @@
import type { IUserFeatureRepository } from "@calcom/features/flags/repositories/UserFeatureRepository";
import { moduleLoader as userFeatureRepositoryModuleLoader } from "../../flags/di/UserFeatureRepository.module";
import type { IUserFeatureRepository } from "@calcom/features/flags/repositories/PrismaUserFeatureRepository";
import { moduleLoader as cachedUserFeatureRepositoryModuleLoader } from "../../flags/di/CachedUserFeatureRepository.module";
import { createContainer } from "../di";
const userFeatureRepositoryContainer = createContainer();
export function getUserFeatureRepository(): IUserFeatureRepository {
userFeatureRepositoryModuleLoader.loadModule(userFeatureRepositoryContainer);
return userFeatureRepositoryContainer.get<IUserFeatureRepository>(userFeatureRepositoryModuleLoader.token);
cachedUserFeatureRepositoryModuleLoader.loadModule(userFeatureRepositoryContainer);
return userFeatureRepositoryContainer.get<IUserFeatureRepository>(
cachedUserFeatureRepositoryModuleLoader.token
);
}
@@ -1,8 +1,8 @@
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 { moduleLoader as cachedFeatureRepositoryModuleLoader } from "@calcom/features/flags/di/CachedFeatureRepository.module";
import { moduleLoader as cachedTeamFeatureRepositoryModuleLoader } from "@calcom/features/flags/di/CachedTeamFeatureRepository.module";
import { moduleLoader as cachedUserFeatureRepositoryModuleLoader } from "@calcom/features/flags/di/CachedUserFeatureRepository.module";
import type { Module } from "@evyweb/ioctopus";
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "../di";
@@ -16,9 +16,9 @@ const loadModule: ReturnType<typeof bindModuleToClassOnToken> = bindModuleToClas
token,
classs: FeatureOptInService,
depsMap: {
featureRepo: featureRepositoryModuleLoader,
teamFeatureRepo: teamFeatureRepositoryModuleLoader,
userFeatureRepo: userFeatureRepositoryModuleLoader,
featureRepo: cachedFeatureRepositoryModuleLoader,
teamFeatureRepo: cachedTeamFeatureRepositoryModuleLoader,
userFeatureRepo: cachedUserFeatureRepositoryModuleLoader,
},
});
@@ -150,6 +150,7 @@ describe("TeamRepository", () => {
organizationSettings: true,
isPlatform: true,
},
orderBy: { id: "asc" },
});
expect(result).toEqual(mockTeams);
});
@@ -207,6 +207,7 @@ export class TeamRepository {
parentId,
},
select,
orderBy: { id: "asc" },
});
}
@@ -1,8 +1,8 @@
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import type { FeatureId, FeatureState } from "@calcom/features/flags/config";
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 type { IFeatureRepository } from "@calcom/features/flags/repositories/PrismaFeatureRepository";
import type { ITeamFeatureRepository } from "@calcom/features/flags/repositories/PrismaTeamFeatureRepository";
import type { IUserFeatureRepository } from "@calcom/features/flags/repositories/PrismaUserFeatureRepository";
import { 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";
@@ -0,0 +1,23 @@
import { createModule, type ModuleLoader } from "@calcom/features/di/di";
import type { Module } from "@evyweb/ioctopus";
import { CachedFeatureRepository } from "../repositories/CachedFeatureRepository";
import { moduleLoader as prismaFeatureRepositoryModuleLoader } from "./PrismaFeatureRepository.module";
import { FLAGS_DI_TOKENS } from "./tokens";
const thisModule: Module = createModule();
const token: symbol = FLAGS_DI_TOKENS.CACHED_FEATURE_REPOSITORY;
const moduleToken: symbol = FLAGS_DI_TOKENS.CACHED_FEATURE_REPOSITORY_MODULE;
thisModule.bind(token).toClass(CachedFeatureRepository, [prismaFeatureRepositoryModuleLoader.token]);
const loadModule = (container: ReturnType<typeof import("@calcom/features/di/di").createContainer>): void => {
container.load(moduleToken, thisModule);
prismaFeatureRepositoryModuleLoader.loadModule(container);
};
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
export type { CachedFeatureRepository };
@@ -0,0 +1,23 @@
import { createModule, type ModuleLoader } from "@calcom/features/di/di";
import type { Module } from "@evyweb/ioctopus";
import { CachedTeamFeatureRepository } from "../repositories/CachedTeamFeatureRepository";
import { moduleLoader as prismaTeamFeatureRepositoryModuleLoader } from "./PrismaTeamFeatureRepository.module";
import { FLAGS_DI_TOKENS } from "./tokens";
const thisModule: Module = createModule();
const token: symbol = FLAGS_DI_TOKENS.CACHED_TEAM_FEATURE_REPOSITORY;
const moduleToken: symbol = FLAGS_DI_TOKENS.CACHED_TEAM_FEATURE_REPOSITORY_MODULE;
thisModule.bind(token).toClass(CachedTeamFeatureRepository, [prismaTeamFeatureRepositoryModuleLoader.token]);
const loadModule = (container: ReturnType<typeof import("@calcom/features/di/di").createContainer>): void => {
container.load(moduleToken, thisModule);
prismaTeamFeatureRepositoryModuleLoader.loadModule(container);
};
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
export type { CachedTeamFeatureRepository };
@@ -0,0 +1,23 @@
import { createModule, type ModuleLoader } from "@calcom/features/di/di";
import type { Module } from "@evyweb/ioctopus";
import { CachedUserFeatureRepository } from "../repositories/CachedUserFeatureRepository";
import { moduleLoader as prismaUserFeatureRepositoryModuleLoader } from "./PrismaUserFeatureRepository.module";
import { FLAGS_DI_TOKENS } from "./tokens";
const thisModule: Module = createModule();
const token: symbol = FLAGS_DI_TOKENS.CACHED_USER_FEATURE_REPOSITORY;
const moduleToken: symbol = FLAGS_DI_TOKENS.CACHED_USER_FEATURE_REPOSITORY_MODULE;
thisModule.bind(token).toClass(CachedUserFeatureRepository, [prismaUserFeatureRepositoryModuleLoader.token]);
const loadModule = (container: ReturnType<typeof import("@calcom/features/di/di").createContainer>): void => {
container.load(moduleToken, thisModule);
prismaUserFeatureRepositoryModuleLoader.loadModule(container);
};
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
export type { CachedUserFeatureRepository };
@@ -1,16 +1,14 @@
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 type { Module } from "@evyweb/ioctopus";
import { PrismaFeatureRepository } from "../repositories/PrismaFeatureRepository";
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;
const token: symbol = FLAGS_DI_TOKENS.PRISMA_FEATURE_REPOSITORY;
const moduleToken: symbol = FLAGS_DI_TOKENS.PRISMA_FEATURE_REPOSITORY_MODULE;
thisModule.bind(token).toClass(UserFeatureRepository, [prismaModuleLoader.token]);
thisModule.bind(token).toClass(PrismaFeatureRepository, [prismaModuleLoader.token]);
const loadModule = (container: ReturnType<typeof import("@calcom/features/di/di").createContainer>): void => {
container.load(moduleToken, thisModule);
@@ -22,4 +20,4 @@ export const moduleLoader: ModuleLoader = {
loadModule,
};
export type { UserFeatureRepository };
export type { PrismaFeatureRepository };
@@ -1,14 +1,14 @@
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 { PrismaTeamFeatureRepository } from "../repositories/PrismaTeamFeatureRepository";
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;
const token: symbol = FLAGS_DI_TOKENS.PRISMA_TEAM_FEATURE_REPOSITORY;
const moduleToken: symbol = FLAGS_DI_TOKENS.PRISMA_TEAM_FEATURE_REPOSITORY_MODULE;
thisModule.bind(token).toClass(FeatureRepository, [prismaModuleLoader.token]);
thisModule.bind(token).toClass(PrismaTeamFeatureRepository, [prismaModuleLoader.token]);
const loadModule = (container: ReturnType<typeof import("@calcom/features/di/di").createContainer>): void => {
container.load(moduleToken, thisModule);
@@ -20,4 +20,4 @@ export const moduleLoader: ModuleLoader = {
loadModule,
};
export type { FeatureRepository };
export type { PrismaTeamFeatureRepository };
@@ -1,14 +1,14 @@
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 { PrismaUserFeatureRepository } from "../repositories/PrismaUserFeatureRepository";
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;
const token: symbol = FLAGS_DI_TOKENS.PRISMA_USER_FEATURE_REPOSITORY;
const moduleToken: symbol = FLAGS_DI_TOKENS.PRISMA_USER_FEATURE_REPOSITORY_MODULE;
thisModule.bind(token).toClass(TeamFeatureRepository, [prismaModuleLoader.token]);
thisModule.bind(token).toClass(PrismaUserFeatureRepository, [prismaModuleLoader.token]);
const loadModule = (container: ReturnType<typeof import("@calcom/features/di/di").createContainer>): void => {
container.load(moduleToken, thisModule);
@@ -20,4 +20,4 @@ export const moduleLoader: ModuleLoader = {
loadModule,
};
export type { TeamFeatureRepository };
export type { PrismaUserFeatureRepository };
+27 -12
View File
@@ -1,22 +1,37 @@
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;
PRISMA_FEATURE_REPOSITORY: symbol;
PRISMA_FEATURE_REPOSITORY_MODULE: symbol;
CACHED_FEATURE_REPOSITORY: symbol;
CACHED_FEATURE_REPOSITORY_MODULE: symbol;
PRISMA_TEAM_FEATURE_REPOSITORY: symbol;
PRISMA_TEAM_FEATURE_REPOSITORY_MODULE: symbol;
CACHED_TEAM_FEATURE_REPOSITORY: symbol;
CACHED_TEAM_FEATURE_REPOSITORY_MODULE: symbol;
PRISMA_USER_FEATURE_REPOSITORY: symbol;
PRISMA_USER_FEATURE_REPOSITORY_MODULE: symbol;
CACHED_USER_FEATURE_REPOSITORY: symbol;
CACHED_USER_FEATURE_REPOSITORY_MODULE: symbol;
} = {
FEATURES_REPOSITORY: Symbol("FeaturesRepository"),
FEATURES_REPOSITORY_MODULE: Symbol("FeaturesRepositoryModule"),
FEATURE_REPOSITORY: Symbol("FeatureRepository"),
FEATURE_REPOSITORY_MODULE: Symbol("FeatureRepositoryModule"),
PRISMA_FEATURE_REPOSITORY: Symbol("PrismaFeatureRepository"),
PRISMA_FEATURE_REPOSITORY_MODULE: Symbol("PrismaFeatureRepositoryModule"),
TEAM_FEATURE_REPOSITORY: Symbol("TeamFeatureRepository"),
TEAM_FEATURE_REPOSITORY_MODULE: Symbol("TeamFeatureRepositoryModule"),
CACHED_FEATURE_REPOSITORY: Symbol("CachedFeatureRepository"),
CACHED_FEATURE_REPOSITORY_MODULE: Symbol("CachedFeatureRepositoryModule"),
USER_FEATURE_REPOSITORY: Symbol("UserFeatureRepository"),
USER_FEATURE_REPOSITORY_MODULE: Symbol("UserFeatureRepositoryModule"),
PRISMA_TEAM_FEATURE_REPOSITORY: Symbol("PrismaTeamFeatureRepository"),
PRISMA_TEAM_FEATURE_REPOSITORY_MODULE: Symbol("PrismaTeamFeatureRepositoryModule"),
CACHED_TEAM_FEATURE_REPOSITORY: Symbol("CachedTeamFeatureRepository"),
CACHED_TEAM_FEATURE_REPOSITORY_MODULE: Symbol("CachedTeamFeatureRepositoryModule"),
PRISMA_USER_FEATURE_REPOSITORY: Symbol("PrismaUserFeatureRepository"),
PRISMA_USER_FEATURE_REPOSITORY_MODULE: Symbol("PrismaUserFeatureRepositoryModule"),
CACHED_USER_FEATURE_REPOSITORY: Symbol("CachedUserFeatureRepository"),
CACHED_USER_FEATURE_REPOSITORY_MODULE: Symbol("CachedUserFeatureRepositoryModule"),
};
@@ -1151,433 +1151,4 @@ describe("FeaturesRepository Integration Tests", () => {
expect(userFeature).toBeNull();
});
});
describe("getUserFeatureStates", () => {
it("should return 'inherit' when user has no feature row", async () => {
const { user, testFeature, featuresRepository } = await setup();
const result = await featuresRepository.getUserFeatureStates({
userId: user.id,
featureIds: [testFeature],
});
expect(result[testFeature]).toBe("inherit");
});
it("should return 'enabled' when user has feature enabled", async () => {
const { user, testFeature, featuresRepository } = await setup();
await prisma.feature.create({
data: {
slug: testFeature,
enabled: true,
type: "OPERATIONAL",
},
});
await prisma.userFeatures.create({
data: {
userId: user.id,
featureId: testFeature,
enabled: true,
assignedBy: "test",
},
});
const result = await featuresRepository.getUserFeatureStates({
userId: user.id,
featureIds: [testFeature],
});
expect(result[testFeature]).toBe("enabled");
});
it("should return 'disabled' when user has feature disabled", async () => {
const { user, testFeature, featuresRepository } = await setup();
await prisma.feature.create({
data: {
slug: testFeature,
enabled: true,
type: "OPERATIONAL",
},
});
await prisma.userFeatures.create({
data: {
userId: user.id,
featureId: testFeature,
enabled: false,
assignedBy: "test",
},
});
const result = await featuresRepository.getUserFeatureStates({
userId: user.id,
featureIds: [testFeature],
});
expect(result[testFeature]).toBe("disabled");
});
it("should return states for multiple features in a single query", async () => {
const { user, testFeature, featuresRepository } = await setup();
const testFeature2 = `${testFeature}-2` as FeatureId;
await prisma.feature.createMany({
data: [
{ slug: testFeature, enabled: true, type: "OPERATIONAL" },
{ slug: testFeature2, enabled: true, type: "OPERATIONAL" },
],
});
await prisma.userFeatures.createMany({
data: [
{ userId: user.id, featureId: testFeature, enabled: true, assignedBy: "test" },
{ userId: user.id, featureId: testFeature2, enabled: false, assignedBy: "test" },
],
});
const result = await featuresRepository.getUserFeatureStates({
userId: user.id,
featureIds: [testFeature, testFeature2],
});
expect(result[testFeature]).toBe("enabled");
expect(result[testFeature2]).toBe("disabled");
// Clean up extra feature
await prisma.userFeatures.deleteMany({
where: { userId: user.id, featureId: testFeature2 },
});
await prisma.feature.deleteMany({
where: { slug: testFeature2 },
});
});
});
describe("getTeamsFeatureStates", () => {
it("should return empty object for empty teamIds array", async () => {
const { testFeature, featuresRepository } = await setup();
const result = await featuresRepository.getTeamsFeatureStates({
teamIds: [],
featureIds: [testFeature],
});
expect(result).toEqual({});
});
it("should return inherit (missing key) when team has no feature row", async () => {
const { team, testFeature, featuresRepository } = await setup();
const result = await featuresRepository.getTeamsFeatureStates({
teamIds: [team.id],
featureIds: [testFeature],
});
// Teams without explicit state are not in the result (caller should default to 'inherit')
expect(result[testFeature]?.[team.id]).toBeUndefined();
});
it("should return 'enabled' when team has feature enabled", async () => {
const { team, testFeature, featuresRepository } = await setup();
await prisma.feature.create({
data: { slug: testFeature, enabled: true, type: "OPERATIONAL" },
});
await prisma.teamFeatures.create({
data: {
teamId: team.id,
featureId: testFeature,
enabled: true,
assignedBy: "test",
},
});
const result = await featuresRepository.getTeamsFeatureStates({
teamIds: [team.id],
featureIds: [testFeature],
});
expect(result[testFeature]?.[team.id]).toBe("enabled");
});
it("should return 'disabled' when team has feature disabled", async () => {
const { team, testFeature, featuresRepository } = await setup();
await prisma.feature.create({
data: { slug: testFeature, enabled: true, type: "OPERATIONAL" },
});
await prisma.teamFeatures.create({
data: {
teamId: team.id,
featureId: testFeature,
enabled: false,
assignedBy: "test",
},
});
const result = await featuresRepository.getTeamsFeatureStates({
teamIds: [team.id],
featureIds: [testFeature],
});
expect(result[testFeature]?.[team.id]).toBe("disabled");
});
it("should return states for multiple teams in a single query", async () => {
const { team, testFeature, featuresRepository } = await setup();
const secondTeam = await prisma.team.create({
data: { name: `Second Test Team ${uniqueId()}`, slug: `second-test-team-${uniqueId()}` },
});
await prisma.feature.create({
data: { slug: testFeature, enabled: true, type: "OPERATIONAL" },
});
await prisma.teamFeatures.createMany({
data: [
{ teamId: team.id, featureId: testFeature, enabled: true, assignedBy: "test" },
{ teamId: secondTeam.id, featureId: testFeature, enabled: false, assignedBy: "test" },
],
});
const result = await featuresRepository.getTeamsFeatureStates({
teamIds: [team.id, secondTeam.id],
featureIds: [testFeature],
});
expect(result[testFeature]?.[team.id]).toBe("enabled");
expect(result[testFeature]?.[secondTeam.id]).toBe("disabled");
// Clean up
await prisma.teamFeatures.deleteMany({
where: { teamId: secondTeam.id },
});
await prisma.team.delete({
where: { id: secondTeam.id },
});
});
it("should only return teams with explicit state, not all requested teams", async () => {
const { team, testFeature, featuresRepository } = await setup();
const secondTeam = await prisma.team.create({
data: { name: `Second Test Team ${uniqueId()}`, slug: `second-test-team-${uniqueId()}` },
});
await prisma.feature.create({
data: { slug: testFeature, enabled: true, type: "OPERATIONAL" },
});
// Only set feature for team, not secondTeam
await prisma.teamFeatures.create({
data: {
teamId: team.id,
featureId: testFeature,
enabled: true,
assignedBy: "test",
},
});
const result = await featuresRepository.getTeamsFeatureStates({
teamIds: [team.id, secondTeam.id],
featureIds: [testFeature],
});
expect(result[testFeature]?.[team.id]).toBe("enabled");
expect(result[testFeature]?.[secondTeam.id]).toBeUndefined(); // Not in result, caller defaults to 'inherit'
// Clean up
await prisma.team.delete({
where: { id: secondTeam.id },
});
});
it("should return states for multiple features across teams", async () => {
const { team, testFeature, featuresRepository } = await setup();
const secondFeature = `${testFeature}-two` as FeatureId;
await prisma.feature.createMany({
data: [
{ slug: testFeature, enabled: true, type: "OPERATIONAL" },
{ slug: secondFeature, enabled: true, type: "OPERATIONAL" },
],
});
await prisma.teamFeatures.createMany({
data: [
{ teamId: team.id, featureId: testFeature, enabled: true, assignedBy: "test" },
{ teamId: team.id, featureId: secondFeature, enabled: false, assignedBy: "test" },
],
});
const result = await featuresRepository.getTeamsFeatureStates({
teamIds: [team.id],
featureIds: [testFeature, secondFeature],
});
expect(result[testFeature]?.[team.id]).toBe("enabled");
expect(result[secondFeature]?.[team.id]).toBe("disabled");
await prisma.teamFeatures.deleteMany({
where: { teamId: team.id, featureId: { in: [testFeature, secondFeature] } },
});
await prisma.feature.deleteMany({
where: { slug: secondFeature },
});
});
});
describe("getUserAutoOptIn", () => {
it("should return false when user has autoOptInFeatures=false (default)", async () => {
const { user, featuresRepository } = await setup();
const result = await featuresRepository.getUserAutoOptIn(user.id);
expect(result).toBe(false);
});
it("should return true when user has autoOptInFeatures=true", async () => {
const { user, featuresRepository } = await setup();
await prisma.user.update({
where: { id: user.id },
data: { autoOptInFeatures: true },
});
const result = await featuresRepository.getUserAutoOptIn(user.id);
expect(result).toBe(true);
});
it("should return false for non-existent user", async () => {
const { featuresRepository } = await setup();
const result = await featuresRepository.getUserAutoOptIn(999999);
expect(result).toBe(false);
});
});
describe("getTeamsAutoOptIn", () => {
it("should return empty object for empty teamIds array", async () => {
const { featuresRepository } = await setup();
const result = await featuresRepository.getTeamsAutoOptIn([]);
expect(result).toEqual({});
});
it("should return false for team with autoOptInFeatures=false (default)", async () => {
const { team, featuresRepository } = await setup();
const result = await featuresRepository.getTeamsAutoOptIn([team.id]);
expect(result[team.id]).toBe(false);
});
it("should return true for team with autoOptInFeatures=true", async () => {
const { team, featuresRepository } = await setup();
await prisma.team.update({
where: { id: team.id },
data: { autoOptInFeatures: true },
});
const result = await featuresRepository.getTeamsAutoOptIn([team.id]);
expect(result[team.id]).toBe(true);
});
it("should return values for multiple teams in a single query", async () => {
const { team, featuresRepository } = await setup();
const secondTeam = await prisma.team.create({
data: {
name: `Second Team ${uniqueId()}`,
slug: `second-team-auto-opt-in-${uniqueId()}`,
autoOptInFeatures: true,
},
});
// team has autoOptInFeatures=false (default)
// secondTeam has autoOptInFeatures=true
const result = await featuresRepository.getTeamsAutoOptIn([team.id, secondTeam.id]);
expect(result[team.id]).toBe(false);
expect(result[secondTeam.id]).toBe(true);
// Clean up
await prisma.team.delete({
where: { id: secondTeam.id },
});
});
it("should not include non-existent teams in result", async () => {
const { team, featuresRepository } = await setup();
const result = await featuresRepository.getTeamsAutoOptIn([team.id, 999999]);
expect(result[team.id]).toBe(false);
expect(result[999999]).toBeUndefined();
});
});
describe("setUserAutoOptIn", () => {
it("should set autoOptInFeatures to true", async () => {
const { user, featuresRepository } = await setup();
// Ensure user starts with false
const before = await featuresRepository.getUserAutoOptIn(user.id);
expect(before).toBe(false);
await featuresRepository.setUserAutoOptIn(user.id, true);
const after = await featuresRepository.getUserAutoOptIn(user.id);
expect(after).toBe(true);
});
it("should set autoOptInFeatures to false", async () => {
const { user, featuresRepository } = await setup();
// First set to true
await featuresRepository.setUserAutoOptIn(user.id, true);
const before = await featuresRepository.getUserAutoOptIn(user.id);
expect(before).toBe(true);
// Now set to false
await featuresRepository.setUserAutoOptIn(user.id, false);
const after = await featuresRepository.getUserAutoOptIn(user.id);
expect(after).toBe(false);
});
});
describe("setTeamAutoOptIn", () => {
it("should set autoOptInFeatures to true", async () => {
const { team, featuresRepository } = await setup();
// Ensure team starts with false
const before = await featuresRepository.getTeamsAutoOptIn([team.id]);
expect(before[team.id]).toBe(false);
await featuresRepository.setTeamAutoOptIn(team.id, true);
const after = await featuresRepository.getTeamsAutoOptIn([team.id]);
expect(after[team.id]).toBe(true);
});
it("should set autoOptInFeatures to false", async () => {
const { team, featuresRepository } = await setup();
// First set to true
await featuresRepository.setTeamAutoOptIn(team.id, true);
const before = await featuresRepository.getTeamsAutoOptIn([team.id]);
expect(before[team.id]).toBe(true);
// Now set to false
await featuresRepository.setTeamAutoOptIn(team.id, false);
const after = await featuresRepository.getTeamsAutoOptIn([team.id]);
expect(after[team.id]).toBe(false);
});
});
});
@@ -1,4 +1,4 @@
import type { FeatureId, FeatureState } from "./config";
import type { FeatureId } from "./config";
/**
* Interface for the core FeaturesRepository.
@@ -7,7 +7,6 @@ import type { FeatureId, FeatureState } from "./config";
export interface IFeaturesRepository {
checkIfFeatureIsEnabledGlobally(slug: FeatureId): Promise<boolean>;
checkIfUserHasFeature(userId: number, slug: string): Promise<boolean>;
getUserFeaturesStatus(userId: number, slugs: string[]): Promise<Record<string, boolean>>;
checkIfUserHasFeatureNonHierarchical(userId: number, slug: string): Promise<boolean>;
checkIfTeamHasFeature(teamId: number, slug: FeatureId): Promise<boolean>;
getTeamsWithFeatureEnabled(slug: FeatureId): Promise<number[]>;
@@ -21,39 +20,4 @@ export interface IFeaturesRepository {
| { teamId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: string }
| { teamId: number; featureId: FeatureId; state: "inherit" }
): Promise<void>;
/**
* Get user's feature states for multiple features.
* @returns Record<featureId, 'enabled' | 'disabled' | 'inherit'>
*/
getUserFeatureStates(input: {
userId: number;
featureIds: FeatureId[];
}): Promise<Record<string, FeatureState>>;
/**
* Get multiple features' states across multiple teams.
* Optimized for querying many teams for many features.
* @returns Record<featureId, Record<teamId, 'enabled' | 'disabled' | 'inherit'>>
*/
getTeamsFeatureStates(input: {
teamIds: number[];
featureIds: FeatureId[];
}): Promise<Record<string, Record<number, FeatureState>>>;
/**
* Get user's autoOptInFeatures flag.
* @returns Promise<boolean> - True if user has auto opt-in enabled
*/
getUserAutoOptIn(userId: number): Promise<boolean>;
/**
* Get autoOptInFeatures for multiple teams (batch).
* @returns Promise<Record<number, boolean>> - Map of teamId to autoOptInFeatures value
*/
getTeamsAutoOptIn(teamIds: number[]): Promise<Record<number, boolean>>;
/**
* Set user's autoOptInFeatures flag.
*/
setUserAutoOptIn(userId: number, enabled: boolean): Promise<void>;
/**
* Set team's autoOptInFeatures flag.
*/
setTeamAutoOptIn(teamId: number, enabled: boolean): Promise<void>;
}
+1 -233
View File
@@ -3,7 +3,7 @@ import { captureException } from "@sentry/nextjs";
import type { PrismaClient } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import type { AppFlags, FeatureId, FeatureState, TeamFeatures } from "./config";
import type { AppFlags, FeatureId, TeamFeatures } from "./config";
import type { IFeaturesRepository } from "./features.repository.interface";
interface CacheOptions {
@@ -151,62 +151,6 @@ export class FeaturesRepository implements IFeaturesRepository {
}
}
/**
* Checks if a specific user has access to multiple features in a single operation.
*
* @param userId - The ID of the user to check
* @param slugs - Array of feature identifiers to check
* @returns Promise<Record<string, boolean>> - A record mapping each slug to its enabled status
* @throws Error if the feature access check fails
*/
async getUserFeaturesStatus(userId: number, slugs: string[]): Promise<Record<string, boolean>> {
try {
if (slugs.length === 0) {
return {};
}
const featuresStatus: Record<string, boolean> = Object.fromEntries(slugs.map((slug) => [slug, false]));
const userFeatures = await this.prismaClient.userFeatures.findMany({
where: {
userId,
featureId: {
in: slugs,
},
},
select: {
featureId: true,
enabled: true,
},
});
const featuresConfiguredForUser = new Set<string>();
const slugsToCheckAtTeamLevel: string[] = [];
for (const userFeature of userFeatures) {
featuresStatus[userFeature.featureId] = userFeature.enabled;
featuresConfiguredForUser.add(userFeature.featureId);
}
for (const slug of slugs) {
// If the feature is not configured for the user, check team-level
if (!featuresConfiguredForUser.has(slug)) {
slugsToCheckAtTeamLevel.push(slug);
}
}
await Promise.all(slugsToCheckAtTeamLevel.map(async (slug) => {
const hasTeamFeature = await this.checkIfUserBelongsToTeamWithFeature(userId, slug);
featuresStatus[slug] = hasTeamFeature;
}))
return featuresStatus;
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Checks if a specific user has access to a feature, ignoring hierarchical (parent) teams.
* Only checks direct user assignments and direct team memberships — does not traverse parents.
@@ -526,180 +470,4 @@ export class FeaturesRepository implements IFeaturesRepository {
throw err;
}
}
/**
* Get user's feature states for multiple features.
* Uses tri-state semantics:
* - Row with enabled=true → 'enabled'
* - Row with enabled=false → 'disabled'
* - No row → 'inherit' from team/org level
*
* @param input - Object containing userId and featureIds array
* @returns Record<featureId, 'enabled' | 'disabled' | 'inherit'>
*/
async getUserFeatureStates(input: {
userId: number;
featureIds: FeatureId[];
}): Promise<Partial<Record<FeatureId, FeatureState>>> {
const { userId, featureIds } = input;
try {
// Initialize result with all features set to 'inherit'
const result: Partial<Record<FeatureId, FeatureState>> = {};
for (const featureId of featureIds) {
result[featureId] = "inherit";
}
// Query all user features in a single call
const userFeatures = await this.prismaClient.userFeatures.findMany({
where: {
userId,
featureId: { in: featureIds },
},
select: { featureId: true, enabled: true },
});
// Update result with actual values from database
for (const userFeature of userFeatures) {
result[userFeature.featureId as FeatureId] = userFeature.enabled ? "enabled" : "disabled";
}
return result;
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Get multiple features' states across multiple teams.
* Optimized for querying many teams for many features in one call.
* Uses tri-state semantics:
* - Row with enabled=true → 'enabled'
* - Row with enabled=false → 'disabled'
* - No row → 'inherit' from parent team/org level
*
* @param input - Object containing teamIds array and featureIds array
* @returns Record<featureId, Record<teamId, 'enabled' | 'disabled' | 'inherit'>>
*/
async getTeamsFeatureStates(input: {
teamIds: number[];
featureIds: FeatureId[];
}): Promise<Partial<Record<FeatureId, Record<number, FeatureState>>>> {
const { teamIds, featureIds } = input;
if (teamIds.length === 0 || featureIds.length === 0) {
return {} as Partial<Record<FeatureId, Record<number, FeatureState>>>;
}
try {
// Initialize result with all features present to avoid undefined feature keys
const result: Partial<Record<FeatureId, Record<number, FeatureState>>> = {};
for (const featureId of featureIds) {
result[featureId] = {};
}
// Query all team features in a single call
const teamFeatures = await this.prismaClient.teamFeatures.findMany({
where: {
teamId: { in: teamIds },
featureId: { in: featureIds },
},
select: { teamId: true, featureId: true, enabled: true },
});
// Build result map - teams not in the result will default to 'inherit'
for (const teamFeature of teamFeatures) {
const featureStates = result[teamFeature.featureId as FeatureId] ?? {};
featureStates[teamFeature.teamId] = teamFeature.enabled ? "enabled" : "disabled";
result[teamFeature.featureId as FeatureId] = featureStates;
}
return result;
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Get user's autoOptInFeatures flag.
* @param userId - The ID of the user
* @returns Promise<boolean> - True if user has auto opt-in enabled, false otherwise
*/
async getUserAutoOptIn(userId: number): Promise<boolean> {
try {
const user = await this.prismaClient.user.findUnique({
where: { id: userId },
select: { autoOptInFeatures: true },
});
return user?.autoOptInFeatures ?? false;
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Get autoOptInFeatures for multiple teams (batch).
* @param teamIds - Array of team IDs to query
* @returns Promise<Record<number, boolean>> - Map of teamId to autoOptInFeatures value
*/
async getTeamsAutoOptIn(teamIds: number[]): Promise<Record<number, boolean>> {
if (teamIds.length === 0) {
return {};
}
try {
const teams = await this.prismaClient.team.findMany({
where: { id: { in: teamIds } },
select: { id: true, autoOptInFeatures: true },
});
const result: Record<number, boolean> = {};
for (const team of teams) {
result[team.id] = team.autoOptInFeatures;
}
return result;
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Set user's autoOptInFeatures flag.
* @param userId - The ID of the user
* @param enabled - Whether to enable auto opt-in for all features
* @returns Promise<void>
*/
async setUserAutoOptIn(userId: number, enabled: boolean): Promise<void> {
try {
await this.prismaClient.user.update({
where: { id: userId },
data: { autoOptInFeatures: enabled },
});
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Set team's autoOptInFeatures flag.
* @param teamId - The ID of the team
* @param enabled - Whether to enable auto opt-in for all features
* @returns Promise<void>
*/
async setTeamAutoOptIn(teamId: number, enabled: boolean): Promise<void> {
try {
await this.prismaClient.team.update({
where: { id: teamId },
data: { autoOptInFeatures: enabled },
});
} catch (err) {
captureException(err);
throw err;
}
}
}
@@ -0,0 +1,50 @@
import { Memoize, Unmemoize } from "@calcom/features/cache";
import type { FeatureId } from "@calcom/features/flags/config";
import type { FeatureDto } from "@calcom/lib/dto/FeatureDto";
import { FeatureDtoArraySchema, FeatureDtoSchema } from "@calcom/lib/dto/FeatureDto";
import type { IFeatureRepository } from "./PrismaFeatureRepository";
const CACHE_PREFIX = "features:global";
const KEY = {
all: (): string => `${CACHE_PREFIX}:all`,
bySlug: (slug: string): string => `${CACHE_PREFIX}:slug:${slug}`,
};
export class CachedFeatureRepository implements IFeatureRepository {
constructor(private prismaFeatureRepository: IFeatureRepository) {}
@Memoize({
key: KEY.all,
schema: FeatureDtoArraySchema,
})
async findAll(): Promise<FeatureDto[]> {
return this.prismaFeatureRepository.findAll();
}
@Memoize({
key: KEY.bySlug,
schema: FeatureDtoSchema.nullable(),
})
async findBySlug(slug: string): Promise<FeatureDto | null> {
return this.prismaFeatureRepository.findBySlug(slug);
}
@Unmemoize({
keys: (input: { featureId: FeatureId }) => [KEY.bySlug(input.featureId), KEY.all()],
})
async update(input: { featureId: FeatureId; enabled: boolean; updatedBy?: number }): Promise<FeatureDto> {
return this.prismaFeatureRepository.update(input);
}
/**
* Checks if a feature is enabled globally in the application.
* Uses the cached findBySlug method to avoid hitting the database on every request.
* @param slug - The feature flag identifier to check
* @returns Promise<boolean> - True if the feature is enabled globally, false otherwise
*/
async checkIfFeatureIsEnabledGlobally(slug: string): Promise<boolean> {
const feature = await this.findBySlug(slug);
return Boolean(feature && feature.enabled);
}
}
@@ -0,0 +1,94 @@
import { z } from "zod";
import { Memoize, Unmemoize } from "@calcom/features/cache";
import type { TeamFeaturesDto } from "@calcom/lib/dto/TeamFeaturesDto";
import { TeamFeaturesDtoSchema } from "@calcom/lib/dto/TeamFeaturesDto";
import type { FeatureId, TeamFeatures } from "../config";
import type { ITeamFeatureRepository } from "./PrismaTeamFeatureRepository";
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}`,
enabledFeatures: (teamId: number): string => `${CACHE_PREFIX}:enabledFeatures:${teamId}`,
};
const teamFeaturesSchema = z.record(z.string(), z.boolean()).nullable();
export class CachedTeamFeatureRepository implements ITeamFeatureRepository {
constructor(private prismaTeamFeatureRepository: ITeamFeatureRepository) {}
@Memoize({
key: KEY.byTeamIdAndFeatureId,
schema: TeamFeaturesDtoSchema,
})
async findByTeamIdAndFeatureId(teamId: number, featureId: FeatureId): Promise<TeamFeaturesDto | null> {
return this.prismaTeamFeatureRepository.findByTeamIdAndFeatureId(teamId, featureId);
}
async findByTeamIdsAndFeatureIds(
teamIds: number[],
featureIds: FeatureId[]
): Promise<Partial<Record<FeatureId, Record<number, TeamFeaturesDto>>>> {
return this.prismaTeamFeatureRepository.findByTeamIdsAndFeatureIds(teamIds, featureIds);
}
@Unmemoize({
keys: (teamId: number, featureId: FeatureId) => [
KEY.byTeamIdAndFeatureId(teamId, featureId),
KEY.enabledFeatures(teamId),
],
})
async upsert(
teamId: number,
featureId: FeatureId,
enabled: boolean,
assignedBy: string
): Promise<TeamFeaturesDto> {
return this.prismaTeamFeatureRepository.upsert(teamId, featureId, enabled, assignedBy);
}
@Unmemoize({
keys: (teamId: number, featureId: FeatureId) => [
KEY.byTeamIdAndFeatureId(teamId, featureId),
KEY.enabledFeatures(teamId),
],
})
async delete(teamId: number, featureId: FeatureId): Promise<void> {
return this.prismaTeamFeatureRepository.delete(teamId, featureId);
}
@Memoize({
key: KEY.autoOptInByTeamId,
schema: booleanSchema,
})
async findAutoOptInByTeamId(teamId: number): Promise<boolean> {
return this.prismaTeamFeatureRepository.findAutoOptInByTeamId(teamId);
}
async findAutoOptInByTeamIds(teamIds: number[]): Promise<Record<number, boolean>> {
return this.prismaTeamFeatureRepository.findAutoOptInByTeamIds(teamIds);
}
@Unmemoize({
keys: (teamId: number) => [KEY.autoOptInByTeamId(teamId)],
})
async setAutoOptIn(teamId: number, enabled: boolean): Promise<void> {
return this.prismaTeamFeatureRepository.setAutoOptIn(teamId, enabled);
}
async checkIfTeamHasFeature(teamId: number, featureId: FeatureId): Promise<boolean> {
return this.prismaTeamFeatureRepository.checkIfTeamHasFeature(teamId, featureId);
}
@Memoize({
key: KEY.enabledFeatures,
schema: teamFeaturesSchema,
})
async getEnabledFeatures(teamId: number): Promise<TeamFeatures | null> {
return this.prismaTeamFeatureRepository.getEnabledFeatures(teamId);
}
}
@@ -0,0 +1,74 @@
import { Memoize, Unmemoize } from "@calcom/features/cache";
import type { UserFeaturesDto } from "@calcom/lib/dto/UserFeaturesDto";
import { UserFeaturesDtoSchema } from "@calcom/lib/dto/UserFeaturesDto";
import type { FeatureId } from "../config";
import type { IUserFeatureRepository } from "./PrismaUserFeatureRepository";
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 class CachedUserFeatureRepository implements IUserFeatureRepository {
constructor(private prismaUserFeatureRepository: IUserFeatureRepository) {}
@Memoize({
key: KEY.byUserIdAndFeatureId,
schema: UserFeaturesDtoSchema,
})
async findByUserIdAndFeatureId(userId: number, featureId: FeatureId): Promise<UserFeaturesDto | null> {
return this.prismaUserFeatureRepository.findByUserIdAndFeatureId(userId, featureId);
}
async findByUserIdAndFeatureIds(
userId: number,
featureIds: FeatureId[]
): Promise<Partial<Record<FeatureId, UserFeaturesDto>>> {
return this.prismaUserFeatureRepository.findByUserIdAndFeatureIds(userId, featureIds);
}
@Unmemoize({
keys: (userId: number, featureId: FeatureId) => [KEY.byUserIdAndFeatureId(userId, featureId)],
})
async upsert(
userId: number,
featureId: FeatureId,
enabled: boolean,
assignedBy: string
): Promise<UserFeaturesDto> {
return this.prismaUserFeatureRepository.upsert(userId, featureId, enabled, assignedBy);
}
@Unmemoize({
keys: (userId: number, featureId: FeatureId) => [KEY.byUserIdAndFeatureId(userId, featureId)],
})
async delete(userId: number, featureId: FeatureId): Promise<void> {
return this.prismaUserFeatureRepository.delete(userId, featureId);
}
@Memoize({
key: KEY.autoOptInByUserId,
schema: booleanSchema,
})
async findAutoOptInByUserId(userId: number): Promise<boolean> {
return this.prismaUserFeatureRepository.findAutoOptInByUserId(userId);
}
@Unmemoize({
keys: (userId: number) => [KEY.autoOptInByUserId(userId)],
})
async setAutoOptIn(userId: number, enabled: boolean): Promise<void> {
return this.prismaUserFeatureRepository.setAutoOptIn(userId, enabled);
}
async checkIfUserHasFeature(userId: number, slug: string): Promise<boolean> {
return this.prismaUserFeatureRepository.checkIfUserHasFeature(userId, slug);
}
async checkIfUserHasFeatureNonHierarchical(userId: number, slug: string): Promise<boolean> {
return this.prismaUserFeatureRepository.checkIfUserHasFeatureNonHierarchical(userId, slug);
}
}
@@ -1,42 +0,0 @@
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,70 @@
import type { FeatureId } from "@calcom/features/flags/config";
import type { FeatureDto } from "@calcom/lib/dto/FeatureDto";
import type { PrismaClient } from "@calcom/prisma/client";
export interface IFeatureRepository {
findAll(): Promise<FeatureDto[]>;
findBySlug(slug: string): Promise<FeatureDto | null>;
update(input: { featureId: FeatureId; enabled: boolean; updatedBy?: number }): Promise<FeatureDto>;
}
export class PrismaFeatureRepository implements IFeatureRepository {
private prisma: PrismaClient;
constructor(prisma: PrismaClient) {
this.prisma = prisma;
}
async findAll(): Promise<FeatureDto[]> {
return this.prisma.feature.findMany({
orderBy: { slug: "asc" },
select: {
slug: true,
enabled: true,
description: true,
type: true,
stale: true,
lastUsedAt: true,
createdAt: true,
updatedAt: true,
updatedBy: true,
},
});
}
async findBySlug(slug: string): Promise<FeatureDto | null> {
return this.prisma.feature.findUnique({
where: { slug },
select: {
slug: true,
enabled: true,
description: true,
type: true,
stale: true,
lastUsedAt: true,
createdAt: true,
updatedAt: true,
updatedBy: true,
},
});
}
async update(input: { featureId: FeatureId; enabled: boolean; updatedBy?: number }): Promise<FeatureDto> {
const { featureId, enabled, updatedBy } = input;
return this.prisma.feature.update({
where: { slug: featureId },
data: { enabled, updatedBy, updatedAt: new Date() },
select: {
slug: true,
enabled: true,
description: true,
type: true,
stale: true,
lastUsedAt: true,
createdAt: true,
updatedAt: true,
updatedBy: true,
},
});
}
}
@@ -1,16 +1,10 @@
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";
import { captureException } from "@sentry/nextjs";
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}`,
};
import type { TeamFeaturesDto } from "@calcom/lib/dto/TeamFeaturesDto";
import type { PrismaClient } from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import type { FeatureId, TeamFeatures } from "../config";
export interface ITeamFeatureRepository {
findByTeamIdAndFeatureId(teamId: number, featureId: FeatureId): Promise<TeamFeaturesDto | null>;
@@ -28,19 +22,17 @@ export interface ITeamFeatureRepository {
findAutoOptInByTeamId(teamId: number): Promise<boolean>;
findAutoOptInByTeamIds(teamIds: number[]): Promise<Record<number, boolean>>;
setAutoOptIn(teamId: number, enabled: boolean): Promise<void>;
checkIfTeamHasFeature(teamId: number, featureId: FeatureId): Promise<boolean>;
getEnabledFeatures(teamId: number): Promise<TeamFeatures | null>;
}
export class TeamFeatureRepository implements ITeamFeatureRepository {
export class PrismaTeamFeatureRepository 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: {
@@ -87,9 +79,6 @@ export class TeamFeatureRepository implements ITeamFeatureRepository {
return result;
}
@Unmemoize({
keys: (teamId: number, featureId: FeatureId) => [KEY.byTeamIdAndFeatureId(teamId, featureId)],
})
async upsert(
teamId: number,
featureId: FeatureId,
@@ -133,9 +122,6 @@ export class TeamFeatureRepository implements ITeamFeatureRepository {
};
}
@Unmemoize({
keys: (teamId: number, featureId: FeatureId) => [KEY.byTeamIdAndFeatureId(teamId, featureId)],
})
async delete(teamId: number, featureId: FeatureId): Promise<void> {
await this.prisma.teamFeatures.delete({
where: {
@@ -147,10 +133,6 @@ export class TeamFeatureRepository implements ITeamFeatureRepository {
});
}
@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 },
@@ -178,13 +160,94 @@ export class TeamFeatureRepository implements ITeamFeatureRepository {
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 },
});
}
/**
* Checks if a team or any of its ancestors has access to a specific feature.
* Uses a recursive CTE raw SQL query for performance.
* Uses tri-state semantics: only treats as enabled if TeamFeatures row exists AND enabled=true.
*/
async checkIfTeamHasFeature(teamId: number, featureId: FeatureId): Promise<boolean> {
try {
const teamFeature = await this.prisma.teamFeatures.findUnique({
where: {
teamId_featureId: {
teamId,
featureId,
},
},
select: { enabled: true },
});
if (teamFeature) return teamFeature.enabled;
const query = Prisma.sql`
WITH RECURSIVE TeamHierarchy AS (
SELECT id, "parentId",
CASE WHEN EXISTS (
SELECT 1 FROM "TeamFeatures" tf
WHERE tf."teamId" = id AND tf."featureId" = ${featureId} AND tf."enabled" = true
) THEN true ELSE false END as has_feature
FROM "Team"
WHERE id = ${teamId}
UNION ALL
SELECT p.id, p."parentId",
CASE WHEN EXISTS (
SELECT 1 FROM "TeamFeatures" tf
WHERE tf."teamId" = p.id AND tf."featureId" = ${featureId} AND tf."enabled" = true
) THEN true ELSE false END as has_feature
FROM "Team" p
INNER JOIN TeamHierarchy c ON p.id = c."parentId"
WHERE NOT c.has_feature
)
SELECT 1
FROM TeamHierarchy
WHERE has_feature = true
LIMIT 1;
`;
const result = await this.prisma.$queryRaw<unknown[]>(query);
return result.length > 0;
} catch (err) {
captureException(err);
console.error(
`Recursive feature check failed for team ${teamId}, feature ${featureId}:`,
err instanceof Error ? err.message : err
);
throw err;
}
}
/**
* Gets all features enabled for a specific team in a map format.
*/
async getEnabledFeatures(teamId: number): Promise<TeamFeatures | null> {
const result = await this.prisma.teamFeatures.findMany({
where: {
teamId,
enabled: true,
},
select: {
feature: {
select: {
slug: true,
},
},
},
});
if (!result.length) return null;
const features: TeamFeatures = Object.fromEntries(
result.map((teamFeature) => [teamFeature.feature.slug, true])
) as TeamFeatures;
return features;
}
}
@@ -0,0 +1,274 @@
import type { UserFeaturesDto } from "@calcom/lib/dto/UserFeaturesDto";
import type { PrismaClient } from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import { captureException } from "@sentry/nextjs";
import type { FeatureId } from "../config";
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>;
checkIfUserHasFeature(userId: number, slug: string): Promise<boolean>;
checkIfUserHasFeatureNonHierarchical(userId: number, slug: string): Promise<boolean>;
}
export class PrismaUserFeatureRepository implements IUserFeatureRepository {
private prisma: PrismaClient;
constructor(prisma: PrismaClient) {
this.prisma = prisma;
}
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>>> {
if (featureIds.length === 0) {
return {};
}
const results = await this.prisma.userFeatures.findMany({
where: {
userId,
featureId: {
in: featureIds,
},
},
select: {
userId: true,
featureId: true,
enabled: true,
assignedBy: true,
updatedAt: true,
},
});
const result: Partial<Record<FeatureId, UserFeaturesDto>> = {};
for (const userFeature of results) {
result[userFeature.featureId as FeatureId] = this.toDto(userFeature);
}
return result;
}
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,
};
}
async delete(userId: number, featureId: FeatureId): Promise<void> {
await this.prisma.userFeatures.delete({
where: {
userId_featureId: {
userId,
featureId,
},
},
});
}
async findAutoOptInByUserId(userId: number): Promise<boolean> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { autoOptInFeatures: true },
});
return user?.autoOptInFeatures ?? false;
}
async setAutoOptIn(userId: number, enabled: boolean): Promise<void> {
await this.prisma.user.update({
where: { id: userId },
data: { autoOptInFeatures: enabled },
});
}
/**
* Checks if a specific user has access to a feature based on user and team assignments.
* Uses tri-state semantics:
* - Row with enabled=true → feature is enabled
* - Row with enabled=false → feature is explicitly disabled (blocks inheritance)
* - No row → inherit from team/org level
*/
async checkIfUserHasFeature(userId: number, slug: string): Promise<boolean> {
try {
const userFeature = await this.prisma.userFeatures.findFirst({
where: {
userId,
featureId: slug,
},
select: { enabled: true },
});
if (userFeature) {
return userFeature.enabled;
}
const userBelongsToTeamWithFeature = await this.checkIfUserBelongsToTeamWithFeature(userId, slug);
return userBelongsToTeamWithFeature;
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Checks if a specific user has access to a feature, ignoring hierarchical (parent) teams.
* Only checks direct user assignments and direct team memberships — does not traverse parents.
*/
async checkIfUserHasFeatureNonHierarchical(userId: number, slug: string): Promise<boolean> {
try {
const userFeature = await this.prisma.userFeatures.findFirst({
where: {
userId,
featureId: slug,
},
select: { enabled: true },
});
if (userFeature) {
return userFeature.enabled;
}
const userBelongsToTeamWithFeature = await this.checkIfUserBelongsToTeamWithFeatureNonHierarchical(
userId,
slug
);
return userBelongsToTeamWithFeature;
} catch (err) {
captureException(err);
throw err;
}
}
private async checkIfUserBelongsToTeamWithFeature(userId: number, slug: string): Promise<boolean> {
try {
const query = Prisma.sql`
WITH RECURSIVE TeamHierarchy AS (
-- Start with teams the user belongs to
SELECT DISTINCT t.id, t."parentId",
CASE WHEN EXISTS (
SELECT 1 FROM "TeamFeatures" tf
WHERE tf."teamId" = t.id AND tf."featureId" = ${slug} AND tf."enabled" = true
) THEN true ELSE false END as has_feature
FROM "Team" t
INNER JOIN "Membership" m ON m."teamId" = t.id
WHERE m."userId" = ${userId} AND m.accepted = true
UNION ALL
-- Recursively get parent teams
SELECT DISTINCT p.id, p."parentId",
CASE WHEN EXISTS (
SELECT 1 FROM "TeamFeatures" tf
WHERE tf."teamId" = p.id AND tf."featureId" = ${slug} AND tf."enabled" = true
) THEN true ELSE false END as has_feature
FROM "Team" p
INNER JOIN TeamHierarchy c ON p.id = c."parentId"
WHERE NOT c.has_feature -- Stop recursion if we found a team with the feature
)
SELECT 1
FROM TeamHierarchy
WHERE has_feature = true
LIMIT 1;
`;
const result = await this.prisma.$queryRaw<unknown[]>(query);
return result.length > 0;
} catch (err) {
captureException(err);
throw err;
}
}
private async checkIfUserBelongsToTeamWithFeatureNonHierarchical(
userId: number,
slug: string
): Promise<boolean> {
try {
const query = Prisma.sql`
SELECT 1
FROM "Team" t
INNER JOIN "Membership" m ON m."teamId" = t.id
WHERE m."userId" = ${userId}
AND m.accepted = true
AND EXISTS (
SELECT 1
FROM "TeamFeatures" tf
WHERE tf."teamId" = t.id
AND tf."featureId" = ${slug}
AND tf."enabled" = true
)
LIMIT 1;
`;
const result = await this.prisma.$queryRaw<unknown[]>(query);
return result.length > 0;
} catch (err) {
captureException(err);
throw err;
}
}
}
@@ -1,158 +0,0 @@
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 },
});
}
}
@@ -1,11 +1,15 @@
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";
import { CachedFeatureRepository } from "../CachedFeatureRepository";
import { PrismaFeatureRepository } from "../PrismaFeatureRepository";
interface MockPrisma {
feature: {
findMany: ReturnType<typeof vi.fn>;
findUnique: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
}
@@ -24,6 +28,8 @@ function createMockPrisma(): MockPrisma {
return {
feature: {
findMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
};
}
@@ -34,17 +40,102 @@ vi.mock("@calcom/features/di/containers/Redis", () => ({
getRedisService: () => mockRedis,
}));
describe("FeatureRepository", () => {
describe("CachedFeatureRepository", () => {
let mockPrisma: MockPrisma;
let repository: FeatureRepository;
let prismaRepository: PrismaFeatureRepository;
let repository: CachedFeatureRepository;
beforeEach(() => {
mockRedis = createMockRedis();
mockPrisma = createMockPrisma();
repository = new FeatureRepository(mockPrisma as unknown as PrismaClient);
prismaRepository = new PrismaFeatureRepository(mockPrisma as unknown as PrismaClient);
repository = new CachedFeatureRepository(prismaRepository);
vi.clearAllMocks();
});
describe("findBySlug", () => {
it("should return cached feature on cache hit", async () => {
const cachedFeature = {
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(cachedFeature);
const result = await repository.findBySlug("feature-1");
expect(result).toEqual(cachedFeature);
expect(mockRedis.get).toHaveBeenCalledWith("features:global:slug:feature-1");
expect(mockPrisma.feature.findUnique).not.toHaveBeenCalled();
});
it("should fetch from database and cache on cache miss", async () => {
const dbFeature = {
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.findUnique).mockResolvedValue(dbFeature);
vi.mocked(mockRedis.set).mockResolvedValue("OK");
const result = await repository.findBySlug("feature-1");
expect(result).toEqual(dbFeature);
expect(mockRedis.get).toHaveBeenCalledWith("features:global:slug:feature-1");
expect(mockPrisma.feature.findUnique).toHaveBeenCalledWith({
where: { slug: "feature-1" },
select: {
slug: true,
enabled: true,
description: true,
type: true,
stale: true,
lastUsedAt: true,
createdAt: true,
updatedAt: true,
updatedBy: true,
},
});
expect(mockRedis.set).toHaveBeenCalled();
});
it("should return null when feature does not exist", async () => {
vi.mocked(mockRedis.get).mockResolvedValue(null);
vi.mocked(mockPrisma.feature.findUnique).mockResolvedValue(null);
const result = await repository.findBySlug("non-existent");
expect(result).toBeNull();
expect(mockPrisma.feature.findUnique).toHaveBeenCalledWith({
where: { slug: "non-existent" },
select: {
slug: true,
enabled: true,
description: true,
type: true,
stale: true,
lastUsedAt: true,
createdAt: true,
updatedAt: true,
updatedBy: true,
},
});
});
});
describe("findAll", () => {
it("should return cached features on cache hit", async () => {
const cachedFeatures = [
@@ -93,8 +184,110 @@ describe("FeatureRepository", () => {
expect(mockRedis.get).toHaveBeenCalledWith("features:global:all");
expect(mockPrisma.feature.findMany).toHaveBeenCalledWith({
orderBy: { slug: "asc" },
select: {
slug: true,
enabled: true,
description: true,
type: true,
stale: true,
lastUsedAt: true,
createdAt: true,
updatedAt: true,
updatedBy: true,
},
});
expect(mockRedis.set).toHaveBeenCalled();
});
});
describe("checkIfFeatureIsEnabledGlobally", () => {
it("should return true when feature exists and is enabled", async () => {
const cachedFeature = {
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(cachedFeature);
const result = await repository.checkIfFeatureIsEnabledGlobally("feature-1");
expect(result).toBe(true);
});
it("should return false when feature exists but is disabled", async () => {
const cachedFeature = {
slug: "feature-1",
enabled: false,
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(cachedFeature);
const result = await repository.checkIfFeatureIsEnabledGlobally("feature-1");
expect(result).toBe(false);
});
it("should return false when feature does not exist", async () => {
vi.mocked(mockRedis.get).mockResolvedValue(null);
vi.mocked(mockPrisma.feature.findUnique).mockResolvedValue(null);
const result = await repository.checkIfFeatureIsEnabledGlobally("non-existent");
expect(result).toBe(false);
});
});
describe("update", () => {
it("should update feature and invalidate cache", async () => {
const updatedFeature = {
slug: "feature-1",
enabled: false,
description: "Test feature",
type: "RELEASE",
stale: false,
lastUsedAt: null,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
updatedBy: 1,
};
vi.mocked(mockPrisma.feature.update).mockResolvedValue(updatedFeature);
vi.mocked(mockRedis.del).mockResolvedValue(1);
const result = await repository.update({
featureId: "feature-1" as "calendar-cache",
enabled: false,
updatedBy: 1,
});
expect(result).toEqual(updatedFeature);
expect(mockPrisma.feature.update).toHaveBeenCalledWith({
where: { slug: "feature-1" },
data: { enabled: false, updatedBy: 1, updatedAt: expect.any(Date) },
select: {
slug: true,
enabled: true,
description: true,
type: true,
stale: true,
lastUsedAt: true,
createdAt: true,
updatedAt: true,
updatedBy: true,
},
});
expect(mockRedis.del).toHaveBeenCalledWith("features:global:slug:feature-1");
});
});
});
@@ -1,16 +1,19 @@
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";
import { CachedTeamFeatureRepository } from "../CachedTeamFeatureRepository";
import { PrismaTeamFeatureRepository } from "../PrismaTeamFeatureRepository";
interface MockPrisma {
teamFeatures: {
findUnique: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
upsert: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
team: {
findUnique: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
};
}
@@ -29,11 +32,13 @@ function createMockPrisma(): MockPrisma {
return {
teamFeatures: {
findUnique: vi.fn(),
findMany: vi.fn(),
upsert: vi.fn(),
delete: vi.fn(),
},
team: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
};
}
@@ -44,14 +49,16 @@ vi.mock("@calcom/features/di/containers/Redis", () => ({
getRedisService: () => mockRedis,
}));
describe("TeamFeatureRepository", () => {
describe("CachedTeamFeatureRepository", () => {
let mockPrisma: MockPrisma;
let repository: TeamFeatureRepository;
let prismaRepository: PrismaTeamFeatureRepository;
let repository: CachedTeamFeatureRepository;
beforeEach(() => {
mockRedis = createMockRedis();
mockPrisma = createMockPrisma();
repository = new TeamFeatureRepository(mockPrisma as unknown as PrismaClient);
prismaRepository = new PrismaTeamFeatureRepository(mockPrisma as unknown as PrismaClient);
repository = new CachedTeamFeatureRepository(prismaRepository);
vi.clearAllMocks();
});
@@ -124,14 +131,14 @@ describe("TeamFeatureRepository", () => {
});
it("should return features grouped by featureId and teamId", async () => {
const cachedFeature1 = {
const dbFeature1 = {
teamId: 1,
featureId: "feature-1",
enabled: true,
assignedBy: "admin",
updatedAt: new Date("2024-01-01"),
};
const cachedFeature2 = {
const dbFeature2 = {
teamId: 2,
featureId: "feature-1",
enabled: false,
@@ -139,14 +146,16 @@ describe("TeamFeatureRepository", () => {
updatedAt: new Date("2024-01-01"),
};
vi.mocked(mockRedis.get).mockResolvedValueOnce(cachedFeature1).mockResolvedValueOnce(cachedFeature2);
vi.mocked(mockPrisma.teamFeatures.findUnique)
.mockResolvedValueOnce(dbFeature1)
.mockResolvedValueOnce(dbFeature2);
const result = await repository.findByTeamIdsAndFeatureIds([1, 2], ["feature-1"]);
expect(result).toEqual({
"feature-1": {
1: cachedFeature1,
2: cachedFeature2,
1: dbFeature1,
2: dbFeature2,
},
});
});
@@ -258,7 +267,9 @@ describe("TeamFeatureRepository", () => {
});
it("should return auto opt-in values for multiple teams", async () => {
vi.mocked(mockRedis.get).mockResolvedValueOnce(true).mockResolvedValueOnce(false);
vi.mocked(mockPrisma.team.findUnique)
.mockResolvedValueOnce({ id: 1, autoOptInFeatures: true })
.mockResolvedValueOnce({ id: 2, autoOptInFeatures: false });
const result = await repository.findAutoOptInByTeamIds([1, 2]);
@@ -0,0 +1,281 @@
import type { IRedisService } from "@calcom/features/redis/IRedisService";
import type { PrismaClient } from "@calcom/prisma/client";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CachedUserFeatureRepository } from "../CachedUserFeatureRepository";
import { PrismaUserFeatureRepository } from "../PrismaUserFeatureRepository";
interface MockPrisma {
userFeatures: {
findUnique: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
upsert: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
user: {
findUnique: ReturnType<typeof vi.fn>;
update: 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 {
userFeatures: {
findUnique: vi.fn(),
findMany: vi.fn(),
upsert: vi.fn(),
delete: vi.fn(),
},
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
};
}
let mockRedis: IRedisService;
vi.mock("@calcom/features/di/containers/Redis", () => ({
getRedisService: () => mockRedis,
}));
describe("CachedUserFeatureRepository", () => {
let mockPrisma: MockPrisma;
let prismaRepository: PrismaUserFeatureRepository;
let repository: CachedUserFeatureRepository;
beforeEach(() => {
mockRedis = createMockRedis();
mockPrisma = createMockPrisma();
prismaRepository = new PrismaUserFeatureRepository(mockPrisma as unknown as PrismaClient);
repository = new CachedUserFeatureRepository(prismaRepository);
vi.clearAllMocks();
});
describe("findByUserIdAndFeatureId", () => {
it("should return cached user feature on cache hit", async () => {
const cachedFeature = {
userId: 1,
featureId: "feature-1",
enabled: true,
assignedBy: "admin",
updatedAt: new Date("2024-01-01"),
};
vi.mocked(mockRedis.get).mockResolvedValue(cachedFeature);
const result = await repository.findByUserIdAndFeatureId(1, "feature-1");
expect(result).toEqual(cachedFeature);
expect(mockRedis.get).toHaveBeenCalledWith("features:user:1:feature-1");
expect(mockPrisma.userFeatures.findUnique).not.toHaveBeenCalled();
});
it("should fetch from database and cache on cache miss", async () => {
const dbFeature = {
userId: 1,
featureId: "feature-1",
enabled: true,
assignedBy: "admin",
updatedAt: new Date("2024-01-01"),
};
vi.mocked(mockRedis.get).mockResolvedValue(null);
vi.mocked(mockPrisma.userFeatures.findUnique).mockResolvedValue(dbFeature);
vi.mocked(mockRedis.set).mockResolvedValue("OK");
const result = await repository.findByUserIdAndFeatureId(1, "feature-1");
expect(result).toEqual(dbFeature);
expect(mockPrisma.userFeatures.findUnique).toHaveBeenCalledWith({
where: {
userId_featureId: {
userId: 1,
featureId: "feature-1",
},
},
});
expect(mockRedis.set).toHaveBeenCalled();
});
it("should return null when user feature not found", async () => {
vi.mocked(mockRedis.get).mockResolvedValue(null);
vi.mocked(mockPrisma.userFeatures.findUnique).mockResolvedValue(null);
const result = await repository.findByUserIdAndFeatureId(1, "non-existent");
expect(result).toBeNull();
expect(mockRedis.set).not.toHaveBeenCalled();
});
});
describe("findByUserIdAndFeatureIds", () => {
it("should return empty object when featureIds is empty", async () => {
const result = await repository.findByUserIdAndFeatureIds(1, []);
expect(result).toEqual({});
});
it("should return features grouped by featureId", async () => {
const dbFeature1 = {
userId: 1,
featureId: "feature-1",
enabled: true,
assignedBy: "admin",
updatedAt: new Date("2024-01-01"),
};
const dbFeature2 = {
userId: 1,
featureId: "feature-2",
enabled: false,
assignedBy: "admin",
updatedAt: new Date("2024-01-01"),
};
vi.mocked(mockPrisma.userFeatures.findMany).mockResolvedValue([dbFeature1, dbFeature2]);
const result = await repository.findByUserIdAndFeatureIds(1, ["feature-1", "feature-2"]);
expect(result).toEqual({
"feature-1": dbFeature1,
"feature-2": dbFeature2,
});
expect(mockPrisma.userFeatures.findMany).toHaveBeenCalledWith({
where: {
userId: 1,
featureId: {
in: ["feature-1", "feature-2"],
},
},
select: {
userId: true,
featureId: true,
enabled: true,
assignedBy: true,
updatedAt: true,
},
});
});
});
describe("upsert", () => {
it("should upsert user feature and invalidate cache", async () => {
const upsertedFeature = {
userId: 1,
featureId: "feature-1",
enabled: true,
assignedBy: "admin",
updatedAt: new Date("2024-01-01"),
};
vi.mocked(mockPrisma.userFeatures.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.userFeatures.upsert).toHaveBeenCalledWith({
where: {
userId_featureId: {
userId: 1,
featureId: "feature-1",
},
},
create: {
userId: 1,
featureId: "feature-1",
enabled: true,
assignedBy: "admin",
},
update: {
enabled: true,
assignedBy: "admin",
},
});
expect(mockRedis.del).toHaveBeenCalledWith("features:user:1:feature-1");
});
});
describe("delete", () => {
it("should delete user feature and invalidate cache", async () => {
vi.mocked(mockPrisma.userFeatures.delete).mockResolvedValue({
userId: 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.userFeatures.delete).toHaveBeenCalledWith({
where: {
userId_featureId: {
userId: 1,
featureId: "feature-1",
},
},
});
expect(mockRedis.del).toHaveBeenCalledWith("features:user:1:feature-1");
});
});
describe("findAutoOptInByUserId", () => {
it("should return cached auto opt-in value on cache hit", async () => {
vi.mocked(mockRedis.get).mockResolvedValue(true);
const result = await repository.findAutoOptInByUserId(1);
expect(result).toBe(true);
expect(mockRedis.get).toHaveBeenCalledWith("features:user:autoOptIn:1");
expect(mockPrisma.user.findUnique).not.toHaveBeenCalled();
});
it("should fetch from database and cache on cache miss", async () => {
vi.mocked(mockRedis.get).mockResolvedValue(null);
vi.mocked(mockPrisma.user.findUnique).mockResolvedValue({ id: 1, autoOptInFeatures: true });
vi.mocked(mockRedis.set).mockResolvedValue("OK");
const result = await repository.findAutoOptInByUserId(1);
expect(result).toBe(true);
expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({
where: { id: 1 },
select: { autoOptInFeatures: true },
});
expect(mockRedis.set).toHaveBeenCalled();
});
it("should return false when user not found", async () => {
vi.mocked(mockRedis.get).mockResolvedValue(null);
vi.mocked(mockPrisma.user.findUnique).mockResolvedValue(null);
const result = await repository.findAutoOptInByUserId(1);
expect(result).toBe(false);
});
});
describe("setAutoOptIn", () => {
it("should update auto opt-in and invalidate cache", async () => {
vi.mocked(mockPrisma.user.update).mockResolvedValue({ id: 1, autoOptInFeatures: true });
vi.mocked(mockRedis.del).mockResolvedValue(1);
await repository.setAutoOptIn(1, true);
expect(mockPrisma.user.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { autoOptInFeatures: true },
});
expect(mockRedis.del).toHaveBeenCalledWith("features:user:autoOptIn:1");
});
});
});
@@ -1,22 +1,24 @@
import type { PrismaClient } from "@calcom/prisma";
import { getFeatureRepository } from "@calcom/features/di/containers/FeatureRepository";
import type { FeatureId } from "@calcom/features/flags/config";
import type { TAdminToggleFeatureFlagSchema } from "./toggleFeatureFlag.schema";
type GetOptions = {
ctx: {
user: { id: number };
prisma: PrismaClient;
};
input: TAdminToggleFeatureFlagSchema;
};
export const toggleFeatureFlagHandler = async (opts: GetOptions) => {
const { ctx, input } = opts;
const { prisma, user } = ctx;
const { user } = ctx;
const { slug, enabled } = input;
return prisma.feature.update({
where: { slug },
data: { enabled, updatedBy: user.id },
const featureRepository = getFeatureRepository();
return featureRepository.update({
featureId: slug as FeatureId,
enabled,
updatedBy: user.id,
});
};