diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx index 134aff8efb..d05fbfabea 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx @@ -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 ( (featureRepositoryModuleLoader.token); + cachedFeatureRepositoryModuleLoader.loadModule(featureRepositoryContainer); + return featureRepositoryContainer.get(cachedFeatureRepositoryModuleLoader.token); } diff --git a/packages/features/di/containers/TeamFeatureRepository.ts b/packages/features/di/containers/TeamFeatureRepository.ts index 9da1f257dc..9c9d8ddfdb 100644 --- a/packages/features/di/containers/TeamFeatureRepository.ts +++ b/packages/features/di/containers/TeamFeatureRepository.ts @@ -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(teamFeatureRepositoryModuleLoader.token); + cachedTeamFeatureRepositoryModuleLoader.loadModule(teamFeatureRepositoryContainer); + return teamFeatureRepositoryContainer.get( + cachedTeamFeatureRepositoryModuleLoader.token + ); } diff --git a/packages/features/di/containers/UserFeatureRepository.ts b/packages/features/di/containers/UserFeatureRepository.ts index ee7e77ffef..69bc803e32 100644 --- a/packages/features/di/containers/UserFeatureRepository.ts +++ b/packages/features/di/containers/UserFeatureRepository.ts @@ -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(userFeatureRepositoryModuleLoader.token); + cachedUserFeatureRepositoryModuleLoader.loadModule(userFeatureRepositoryContainer); + return userFeatureRepositoryContainer.get( + cachedUserFeatureRepositoryModuleLoader.token + ); } diff --git a/packages/features/di/modules/FeatureOptInService.ts b/packages/features/di/modules/FeatureOptInService.ts index 4d90cf325b..0cd60789ce 100644 --- a/packages/features/di/modules/FeatureOptInService.ts +++ b/packages/features/di/modules/FeatureOptInService.ts @@ -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 = bindModuleToClas token, classs: FeatureOptInService, depsMap: { - featureRepo: featureRepositoryModuleLoader, - teamFeatureRepo: teamFeatureRepositoryModuleLoader, - userFeatureRepo: userFeatureRepositoryModuleLoader, + featureRepo: cachedFeatureRepositoryModuleLoader, + teamFeatureRepo: cachedTeamFeatureRepositoryModuleLoader, + userFeatureRepo: cachedUserFeatureRepositoryModuleLoader, }, }); diff --git a/packages/features/ee/teams/repositories/TeamRepository.test.ts b/packages/features/ee/teams/repositories/TeamRepository.test.ts index 659d43add2..629a4934c9 100644 --- a/packages/features/ee/teams/repositories/TeamRepository.test.ts +++ b/packages/features/ee/teams/repositories/TeamRepository.test.ts @@ -150,6 +150,7 @@ describe("TeamRepository", () => { organizationSettings: true, isPlatform: true, }, + orderBy: { id: "asc" }, }); expect(result).toEqual(mockTeams); }); diff --git a/packages/features/ee/teams/repositories/TeamRepository.ts b/packages/features/ee/teams/repositories/TeamRepository.ts index b33e05fdab..a4f8e62fc7 100644 --- a/packages/features/ee/teams/repositories/TeamRepository.ts +++ b/packages/features/ee/teams/repositories/TeamRepository.ts @@ -207,6 +207,7 @@ export class TeamRepository { parentId, }, select, + orderBy: { id: "asc" }, }); } diff --git a/packages/features/feature-opt-in/services/FeatureOptInService.ts b/packages/features/feature-opt-in/services/FeatureOptInService.ts index 55844f0927..5d413f3fc7 100644 --- a/packages/features/feature-opt-in/services/FeatureOptInService.ts +++ b/packages/features/feature-opt-in/services/FeatureOptInService.ts @@ -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"; diff --git a/packages/features/flags/di/CachedFeatureRepository.module.ts b/packages/features/flags/di/CachedFeatureRepository.module.ts new file mode 100644 index 0000000000..1a1d469db7 --- /dev/null +++ b/packages/features/flags/di/CachedFeatureRepository.module.ts @@ -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): void => { + container.load(moduleToken, thisModule); + prismaFeatureRepositoryModuleLoader.loadModule(container); +}; + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { CachedFeatureRepository }; diff --git a/packages/features/flags/di/CachedTeamFeatureRepository.module.ts b/packages/features/flags/di/CachedTeamFeatureRepository.module.ts new file mode 100644 index 0000000000..205c7e24c0 --- /dev/null +++ b/packages/features/flags/di/CachedTeamFeatureRepository.module.ts @@ -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): void => { + container.load(moduleToken, thisModule); + prismaTeamFeatureRepositoryModuleLoader.loadModule(container); +}; + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { CachedTeamFeatureRepository }; diff --git a/packages/features/flags/di/CachedUserFeatureRepository.module.ts b/packages/features/flags/di/CachedUserFeatureRepository.module.ts new file mode 100644 index 0000000000..3ba9a49dd2 --- /dev/null +++ b/packages/features/flags/di/CachedUserFeatureRepository.module.ts @@ -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): void => { + container.load(moduleToken, thisModule); + prismaUserFeatureRepositoryModuleLoader.loadModule(container); +}; + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { CachedUserFeatureRepository }; diff --git a/packages/features/flags/di/UserFeatureRepository.module.ts b/packages/features/flags/di/PrismaFeatureRepository.module.ts similarity index 62% rename from packages/features/flags/di/UserFeatureRepository.module.ts rename to packages/features/flags/di/PrismaFeatureRepository.module.ts index e63639ba17..280be1e785 100644 --- a/packages/features/flags/di/UserFeatureRepository.module.ts +++ b/packages/features/flags/di/PrismaFeatureRepository.module.ts @@ -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): void => { container.load(moduleToken, thisModule); @@ -22,4 +20,4 @@ export const moduleLoader: ModuleLoader = { loadModule, }; -export type { UserFeatureRepository }; +export type { PrismaFeatureRepository }; diff --git a/packages/features/flags/di/FeatureRepository.module.ts b/packages/features/flags/di/PrismaTeamFeatureRepository.module.ts similarity index 60% rename from packages/features/flags/di/FeatureRepository.module.ts rename to packages/features/flags/di/PrismaTeamFeatureRepository.module.ts index ac1a6ba7b7..225cbeaf2e 100644 --- a/packages/features/flags/di/FeatureRepository.module.ts +++ b/packages/features/flags/di/PrismaTeamFeatureRepository.module.ts @@ -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): void => { container.load(moduleToken, thisModule); @@ -20,4 +20,4 @@ export const moduleLoader: ModuleLoader = { loadModule, }; -export type { FeatureRepository }; +export type { PrismaTeamFeatureRepository }; diff --git a/packages/features/flags/di/TeamFeatureRepository.module.ts b/packages/features/flags/di/PrismaUserFeatureRepository.module.ts similarity index 60% rename from packages/features/flags/di/TeamFeatureRepository.module.ts rename to packages/features/flags/di/PrismaUserFeatureRepository.module.ts index 36a99ae665..795682c808 100644 --- a/packages/features/flags/di/TeamFeatureRepository.module.ts +++ b/packages/features/flags/di/PrismaUserFeatureRepository.module.ts @@ -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): void => { container.load(moduleToken, thisModule); @@ -20,4 +20,4 @@ export const moduleLoader: ModuleLoader = { loadModule, }; -export type { TeamFeatureRepository }; +export type { PrismaUserFeatureRepository }; diff --git a/packages/features/flags/di/tokens.ts b/packages/features/flags/di/tokens.ts index d3ad7522b7..81c72ce4ce 100644 --- a/packages/features/flags/di/tokens.ts +++ b/packages/features/flags/di/tokens.ts @@ -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"), }; diff --git a/packages/features/flags/features.repository.integration-test.ts b/packages/features/flags/features.repository.integration-test.ts index 6a41bc732e..0f3ec5bc4b 100644 --- a/packages/features/flags/features.repository.integration-test.ts +++ b/packages/features/flags/features.repository.integration-test.ts @@ -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); - }); - }); }); diff --git a/packages/features/flags/features.repository.interface.ts b/packages/features/flags/features.repository.interface.ts index 7f0db8eb5a..6f10eeb327 100644 --- a/packages/features/flags/features.repository.interface.ts +++ b/packages/features/flags/features.repository.interface.ts @@ -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; checkIfUserHasFeature(userId: number, slug: string): Promise; - getUserFeaturesStatus(userId: number, slugs: string[]): Promise>; checkIfUserHasFeatureNonHierarchical(userId: number, slug: string): Promise; checkIfTeamHasFeature(teamId: number, slug: FeatureId): Promise; getTeamsWithFeatureEnabled(slug: FeatureId): Promise; @@ -21,39 +20,4 @@ export interface IFeaturesRepository { | { teamId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: string } | { teamId: number; featureId: FeatureId; state: "inherit" } ): Promise; - /** - * Get user's feature states for multiple features. - * @returns Record - */ - getUserFeatureStates(input: { - userId: number; - featureIds: FeatureId[]; - }): Promise>; - /** - * Get multiple features' states across multiple teams. - * Optimized for querying many teams for many features. - * @returns Record> - */ - getTeamsFeatureStates(input: { - teamIds: number[]; - featureIds: FeatureId[]; - }): Promise>>; - /** - * Get user's autoOptInFeatures flag. - * @returns Promise - True if user has auto opt-in enabled - */ - getUserAutoOptIn(userId: number): Promise; - /** - * Get autoOptInFeatures for multiple teams (batch). - * @returns Promise> - Map of teamId to autoOptInFeatures value - */ - getTeamsAutoOptIn(teamIds: number[]): Promise>; - /** - * Set user's autoOptInFeatures flag. - */ - setUserAutoOptIn(userId: number, enabled: boolean): Promise; - /** - * Set team's autoOptInFeatures flag. - */ - setTeamAutoOptIn(teamId: number, enabled: boolean): Promise; } diff --git a/packages/features/flags/features.repository.ts b/packages/features/flags/features.repository.ts index 46fb3700df..2cca3aa4ba 100644 --- a/packages/features/flags/features.repository.ts +++ b/packages/features/flags/features.repository.ts @@ -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> - A record mapping each slug to its enabled status - * @throws Error if the feature access check fails - */ - async getUserFeaturesStatus(userId: number, slugs: string[]): Promise> { - try { - if (slugs.length === 0) { - return {}; - } - - const featuresStatus: Record = 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(); - 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 - */ - async getUserFeatureStates(input: { - userId: number; - featureIds: FeatureId[]; - }): Promise>> { - const { userId, featureIds } = input; - - try { - // Initialize result with all features set to 'inherit' - const result: Partial> = {}; - 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> - */ - async getTeamsFeatureStates(input: { - teamIds: number[]; - featureIds: FeatureId[]; - }): Promise>>> { - const { teamIds, featureIds } = input; - - if (teamIds.length === 0 || featureIds.length === 0) { - return {} as Partial>>; - } - - try { - // Initialize result with all features present to avoid undefined feature keys - const result: Partial>> = {}; - 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 - True if user has auto opt-in enabled, false otherwise - */ - async getUserAutoOptIn(userId: number): Promise { - 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> - Map of teamId to autoOptInFeatures value - */ - async getTeamsAutoOptIn(teamIds: number[]): Promise> { - 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 = {}; - 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 - */ - async setUserAutoOptIn(userId: number, enabled: boolean): Promise { - 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 - */ - async setTeamAutoOptIn(teamId: number, enabled: boolean): Promise { - try { - await this.prismaClient.team.update({ - where: { id: teamId }, - data: { autoOptInFeatures: enabled }, - }); - } catch (err) { - captureException(err); - throw err; - } - } } diff --git a/packages/features/flags/repositories/CachedFeatureRepository.ts b/packages/features/flags/repositories/CachedFeatureRepository.ts new file mode 100644 index 0000000000..9217d6982d --- /dev/null +++ b/packages/features/flags/repositories/CachedFeatureRepository.ts @@ -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 { + return this.prismaFeatureRepository.findAll(); + } + + @Memoize({ + key: KEY.bySlug, + schema: FeatureDtoSchema.nullable(), + }) + async findBySlug(slug: string): Promise { + 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 { + 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 - True if the feature is enabled globally, false otherwise + */ + async checkIfFeatureIsEnabledGlobally(slug: string): Promise { + const feature = await this.findBySlug(slug); + return Boolean(feature && feature.enabled); + } +} diff --git a/packages/features/flags/repositories/CachedTeamFeatureRepository.ts b/packages/features/flags/repositories/CachedTeamFeatureRepository.ts new file mode 100644 index 0000000000..80e3cb3ec9 --- /dev/null +++ b/packages/features/flags/repositories/CachedTeamFeatureRepository.ts @@ -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 { + return this.prismaTeamFeatureRepository.findByTeamIdAndFeatureId(teamId, featureId); + } + + async findByTeamIdsAndFeatureIds( + teamIds: number[], + featureIds: FeatureId[] + ): Promise>>> { + 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 { + 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 { + return this.prismaTeamFeatureRepository.delete(teamId, featureId); + } + + @Memoize({ + key: KEY.autoOptInByTeamId, + schema: booleanSchema, + }) + async findAutoOptInByTeamId(teamId: number): Promise { + return this.prismaTeamFeatureRepository.findAutoOptInByTeamId(teamId); + } + + async findAutoOptInByTeamIds(teamIds: number[]): Promise> { + return this.prismaTeamFeatureRepository.findAutoOptInByTeamIds(teamIds); + } + + @Unmemoize({ + keys: (teamId: number) => [KEY.autoOptInByTeamId(teamId)], + }) + async setAutoOptIn(teamId: number, enabled: boolean): Promise { + return this.prismaTeamFeatureRepository.setAutoOptIn(teamId, enabled); + } + + async checkIfTeamHasFeature(teamId: number, featureId: FeatureId): Promise { + return this.prismaTeamFeatureRepository.checkIfTeamHasFeature(teamId, featureId); + } + + @Memoize({ + key: KEY.enabledFeatures, + schema: teamFeaturesSchema, + }) + async getEnabledFeatures(teamId: number): Promise { + return this.prismaTeamFeatureRepository.getEnabledFeatures(teamId); + } +} diff --git a/packages/features/flags/repositories/CachedUserFeatureRepository.ts b/packages/features/flags/repositories/CachedUserFeatureRepository.ts new file mode 100644 index 0000000000..c3e7e102da --- /dev/null +++ b/packages/features/flags/repositories/CachedUserFeatureRepository.ts @@ -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 { + return this.prismaUserFeatureRepository.findByUserIdAndFeatureId(userId, featureId); + } + + async findByUserIdAndFeatureIds( + userId: number, + featureIds: FeatureId[] + ): Promise>> { + 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 { + 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 { + return this.prismaUserFeatureRepository.delete(userId, featureId); + } + + @Memoize({ + key: KEY.autoOptInByUserId, + schema: booleanSchema, + }) + async findAutoOptInByUserId(userId: number): Promise { + return this.prismaUserFeatureRepository.findAutoOptInByUserId(userId); + } + + @Unmemoize({ + keys: (userId: number) => [KEY.autoOptInByUserId(userId)], + }) + async setAutoOptIn(userId: number, enabled: boolean): Promise { + return this.prismaUserFeatureRepository.setAutoOptIn(userId, enabled); + } + + async checkIfUserHasFeature(userId: number, slug: string): Promise { + return this.prismaUserFeatureRepository.checkIfUserHasFeature(userId, slug); + } + + async checkIfUserHasFeatureNonHierarchical(userId: number, slug: string): Promise { + return this.prismaUserFeatureRepository.checkIfUserHasFeatureNonHierarchical(userId, slug); + } +} diff --git a/packages/features/flags/repositories/FeatureRepository.ts b/packages/features/flags/repositories/FeatureRepository.ts deleted file mode 100644 index aca8eb5c85..0000000000 --- a/packages/features/flags/repositories/FeatureRepository.ts +++ /dev/null @@ -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; -} - -export class FeatureRepository implements IFeatureRepository { - private prisma: PrismaClient; - - constructor(prisma: PrismaClient) { - this.prisma = prisma; - } - - @Memoize({ - key: () => KEY.all(), - schema: FeatureDtoArraySchema, - }) - async findAll(): Promise { - const features = await this.prisma.feature.findMany({ - orderBy: { slug: "asc" }, - }); - return features.map((f) => ({ - slug: f.slug, - enabled: f.enabled, - description: f.description, - type: f.type, - stale: f.stale, - lastUsedAt: f.lastUsedAt, - createdAt: f.createdAt, - updatedAt: f.updatedAt, - updatedBy: f.updatedBy, - })); - } -} diff --git a/packages/features/flags/repositories/PrismaFeatureRepository.ts b/packages/features/flags/repositories/PrismaFeatureRepository.ts new file mode 100644 index 0000000000..62083f7b6b --- /dev/null +++ b/packages/features/flags/repositories/PrismaFeatureRepository.ts @@ -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; + findBySlug(slug: string): Promise; + update(input: { featureId: FeatureId; enabled: boolean; updatedBy?: number }): Promise; +} + +export class PrismaFeatureRepository implements IFeatureRepository { + private prisma: PrismaClient; + + constructor(prisma: PrismaClient) { + this.prisma = prisma; + } + + async findAll(): Promise { + 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 { + 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 { + 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, + }, + }); + } +} diff --git a/packages/features/flags/repositories/TeamFeatureRepository.ts b/packages/features/flags/repositories/PrismaTeamFeatureRepository.ts similarity index 60% rename from packages/features/flags/repositories/TeamFeatureRepository.ts rename to packages/features/flags/repositories/PrismaTeamFeatureRepository.ts index bcdc5fcac5..a12d5933b6 100644 --- a/packages/features/flags/repositories/TeamFeatureRepository.ts +++ b/packages/features/flags/repositories/PrismaTeamFeatureRepository.ts @@ -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; @@ -28,19 +22,17 @@ export interface ITeamFeatureRepository { findAutoOptInByTeamId(teamId: number): Promise; findAutoOptInByTeamIds(teamIds: number[]): Promise>; setAutoOptIn(teamId: number, enabled: boolean): Promise; + checkIfTeamHasFeature(teamId: number, featureId: FeatureId): Promise; + getEnabledFeatures(teamId: number): Promise; } -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 { 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 { 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 { 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 { 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 { + 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(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 { + 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; + } } diff --git a/packages/features/flags/repositories/PrismaUserFeatureRepository.ts b/packages/features/flags/repositories/PrismaUserFeatureRepository.ts new file mode 100644 index 0000000000..7ac3ceaec9 --- /dev/null +++ b/packages/features/flags/repositories/PrismaUserFeatureRepository.ts @@ -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; + findByUserIdAndFeatureIds( + userId: number, + featureIds: FeatureId[] + ): Promise>>; + upsert( + userId: number, + featureId: FeatureId, + enabled: boolean, + assignedBy: string + ): Promise; + delete(userId: number, featureId: FeatureId): Promise; + findAutoOptInByUserId(userId: number): Promise; + setAutoOptIn(userId: number, enabled: boolean): Promise; + checkIfUserHasFeature(userId: number, slug: string): Promise; + checkIfUserHasFeatureNonHierarchical(userId: number, slug: string): Promise; +} + +export class PrismaUserFeatureRepository implements IUserFeatureRepository { + private prisma: PrismaClient; + + constructor(prisma: PrismaClient) { + this.prisma = prisma; + } + + async findByUserIdAndFeatureId(userId: number, featureId: FeatureId): Promise { + const result = await this.prisma.userFeatures.findUnique({ + where: { + userId_featureId: { + userId, + featureId, + }, + }, + }); + if (!result) return null; + return this.toDto(result); + } + + async findByUserIdAndFeatureIds( + userId: number, + featureIds: FeatureId[] + ): Promise>> { + 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> = {}; + 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 { + 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 { + await this.prisma.userFeatures.delete({ + where: { + userId_featureId: { + userId, + featureId, + }, + }, + }); + } + + async findAutoOptInByUserId(userId: number): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { autoOptInFeatures: true }, + }); + return user?.autoOptInFeatures ?? false; + } + + async setAutoOptIn(userId: number, enabled: boolean): Promise { + 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 { + 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 { + 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 { + 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(query); + return result.length > 0; + } catch (err) { + captureException(err); + throw err; + } + } + + private async checkIfUserBelongsToTeamWithFeatureNonHierarchical( + userId: number, + slug: string + ): Promise { + 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(query); + return result.length > 0; + } catch (err) { + captureException(err); + throw err; + } + } +} diff --git a/packages/features/flags/repositories/UserFeatureRepository.ts b/packages/features/flags/repositories/UserFeatureRepository.ts deleted file mode 100644 index ac82c932fc..0000000000 --- a/packages/features/flags/repositories/UserFeatureRepository.ts +++ /dev/null @@ -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; - findByUserIdAndFeatureIds( - userId: number, - featureIds: FeatureId[] - ): Promise>>; - upsert( - userId: number, - featureId: FeatureId, - enabled: boolean, - assignedBy: string - ): Promise; - delete(userId: number, featureId: FeatureId): Promise; - findAutoOptInByUserId(userId: number): Promise; - setAutoOptIn(userId: number, enabled: boolean): Promise; -} - -export class UserFeatureRepository implements IUserFeatureRepository { - private prisma: PrismaClient; - - constructor(prisma: PrismaClient) { - this.prisma = prisma; - } - - @Memoize({ - key: (userId: number, featureId: FeatureId) => KEY.byUserIdAndFeatureId(userId, featureId), - schema: UserFeaturesDtoSchema, - }) - async findByUserIdAndFeatureId(userId: number, featureId: FeatureId): Promise { - const result = await this.prisma.userFeatures.findUnique({ - where: { - userId_featureId: { - userId, - featureId, - }, - }, - }); - if (!result) return null; - return this.toDto(result); - } - - async findByUserIdAndFeatureIds( - userId: number, - featureIds: FeatureId[] - ): Promise>> { - const results = await Promise.all( - featureIds.map(async (featureId) => { - const userFeature = await this.findByUserIdAndFeatureId(userId, featureId); - return { featureId, userFeature }; - }) - ); - - const result: Partial> = {}; - for (const { featureId, userFeature } of results) { - if (userFeature !== null) { - result[featureId] = userFeature; - } - } - - return result; - } - - @Unmemoize({ - keys: (userId: number, featureId: FeatureId) => [KEY.byUserIdAndFeatureId(userId, featureId)], - }) - async upsert( - userId: number, - featureId: FeatureId, - enabled: boolean, - assignedBy: string - ): Promise { - const result = await this.prisma.userFeatures.upsert({ - where: { - userId_featureId: { - userId, - featureId, - }, - }, - create: { - userId, - featureId, - enabled, - assignedBy, - }, - update: { - enabled, - assignedBy, - }, - }); - return this.toDto(result); - } - - private toDto(userFeature: { - userId: number; - featureId: string; - enabled: boolean; - assignedBy: string; - updatedAt: Date; - }): UserFeaturesDto { - return { - userId: userFeature.userId, - featureId: userFeature.featureId, - enabled: userFeature.enabled, - assignedBy: userFeature.assignedBy, - updatedAt: userFeature.updatedAt, - }; - } - - @Unmemoize({ - keys: (userId: number, featureId: FeatureId) => [KEY.byUserIdAndFeatureId(userId, featureId)], - }) - async delete(userId: number, featureId: FeatureId): Promise { - await this.prisma.userFeatures.delete({ - where: { - userId_featureId: { - userId, - featureId, - }, - }, - }); - } - - @Memoize({ - key: (userId: number) => KEY.autoOptInByUserId(userId), - schema: booleanSchema, - }) - async findAutoOptInByUserId(userId: number): Promise { - const user = await this.prisma.user.findUnique({ - where: { id: userId }, - select: { autoOptInFeatures: true }, - }); - return user?.autoOptInFeatures ?? false; - } - - @Unmemoize({ - keys: (userId: number) => [KEY.autoOptInByUserId(userId)], - }) - async setAutoOptIn(userId: number, enabled: boolean): Promise { - await this.prisma.user.update({ - where: { id: userId }, - data: { autoOptInFeatures: enabled }, - }); - } -} diff --git a/packages/features/flags/repositories/__tests__/FeatureRepository.test.ts b/packages/features/flags/repositories/__tests__/FeatureRepository.test.ts index 78792cf74f..5475e86f65 100644 --- a/packages/features/flags/repositories/__tests__/FeatureRepository.test.ts +++ b/packages/features/flags/repositories/__tests__/FeatureRepository.test.ts @@ -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; + findUnique: ReturnType; + update: ReturnType; }; } @@ -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"); + }); + }); }); diff --git a/packages/features/flags/repositories/__tests__/TeamFeatureRepository.test.ts b/packages/features/flags/repositories/__tests__/TeamFeatureRepository.test.ts index 4aaeef23e1..225cd8df2b 100644 --- a/packages/features/flags/repositories/__tests__/TeamFeatureRepository.test.ts +++ b/packages/features/flags/repositories/__tests__/TeamFeatureRepository.test.ts @@ -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; + findMany: ReturnType; upsert: ReturnType; delete: ReturnType; }; team: { findUnique: ReturnType; + findMany: ReturnType; }; } @@ -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]); diff --git a/packages/features/flags/repositories/__tests__/UserFeatureRepository.test.ts b/packages/features/flags/repositories/__tests__/UserFeatureRepository.test.ts new file mode 100644 index 0000000000..baa29b12d6 --- /dev/null +++ b/packages/features/flags/repositories/__tests__/UserFeatureRepository.test.ts @@ -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; + findMany: ReturnType; + upsert: ReturnType; + delete: ReturnType; + }; + user: { + findUnique: ReturnType; + update: ReturnType; + }; +} + +function createMockRedis(): IRedisService { + return { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + expire: vi.fn(), + lrange: vi.fn(), + lpush: vi.fn(), + }; +} + +function createMockPrisma(): MockPrisma { + return { + 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"); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.handler.ts b/packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.handler.ts index 1b4697040c..3910d1c959 100644 --- a/packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/toggleFeatureFlag.handler.ts @@ -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, }); };