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:
co-authored by
unknown
Devin AI
parent
8c123ece25
commit
29ec2b9cb6
@@ -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 };
|
||||
+6
-8
@@ -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 };
|
||||
+5
-5
@@ -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 };
|
||||
+5
-5
@@ -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 };
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+93
-30
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user