refactor: centralize feature flag operations in FeaturesRepository (#21032)

This commit is contained in:
Omar López authored and GitHub committed 2025-05-05 19:06:41 -04:00
1 parent 1aa2f68e73
commit 91288b41cd
18 files changed
+520 -165

No files matched your search

@@ -1,7 +1,7 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import InsightsPage from "~/insights/insights-view";
@@ -15,8 +15,8 @@ export const generateMetadata = async () =>
);
export default async function Page() {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const insightsEnabled = await getFeatureFlag(prisma, "insights");
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
@@ -1,7 +1,7 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import InsightsVirtualQueuesPage from "~/insights/insights-virtual-queues-view";
@@ -15,8 +15,8 @@ export const generateMetadata = async () =>
);
export default async function Page() {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const insightsEnabled = await getFeatureFlag(prisma, "insights");
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
@@ -1,7 +1,7 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import InsightsRoutingPage from "~/insights/insights-routing-view";
@@ -15,8 +15,8 @@ export const generateMetadata = async () =>
);
export default async function Page() {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const insightsEnabled = await getFeatureFlag(prisma, "insights");
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
@@ -1,10 +1,10 @@
import type { GetServerSidePropsResult } from "next";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import prisma from "@calcom/prisma";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
export const getServerSideProps = async (): Promise<GetServerSidePropsResult<{ isOrg: boolean }>> => {
const organizations = await getFeatureFlag(prisma, "organizations");
const featuresRepository = new FeaturesRepository();
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
// Check if organizations are enabled
if (!organizations) {
return {
+6 -4
View File
@@ -4,7 +4,7 @@ import { z } from "zod";
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
import { emailSchema } from "@calcom/lib/emailSchema";
import slugify from "@calcom/lib/slugify";
@@ -24,9 +24,11 @@ const querySchema = z.object({
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const emailVerificationEnabled = await getFeatureFlag(prisma, "email-verification");
const signupDisabled = await getFeatureFlag(prisma, "disable-signup");
const featuresRepository = new FeaturesRepository();
const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"email-verification"
);
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
const token = z.string().optional().parse(ctx.query.token);
const redirectUrlData = z
@@ -5,7 +5,7 @@ import {
getOrganizationSettings,
getVerifiedDomain,
} from "@calcom/features/ee/organizations/lib/orgSettings";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { IS_CALCOM } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
@@ -71,7 +71,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
// Provided by Rewrite from next.config.js
const isOrgProfile = context.query?.isOrgProfile === "1";
const organizationsEnabled = await getFeatureFlag(prisma, "organizations");
const featuresRepository = new FeaturesRepository();
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
log.debug("getServerSideProps", {
isOrgProfile,
+3 -3
View File
@@ -3,7 +3,7 @@ import type { NextApiResponse } from "next";
import calcomSignupHandler from "@calcom/feature-auth/signup/handlers/calcomHandler";
import selfHostedSignupHandler from "@calcom/feature-auth/signup/handlers/selfHostedHandler";
import { type RequestWithUsernameStatus } from "@calcom/features/auth/signup/username";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import getIP from "@calcom/lib/getIP";
import { HttpError } from "@calcom/lib/http-error";
@@ -21,8 +21,8 @@ async function ensureSignupIsEnabled(req: RequestWithUsernameStatus) {
// Still allow signups if there is a team invite
if (token) return;
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const signupDisabled = await getFeatureFlag(prisma, "disable-signup");
const featuresRepository = new FeaturesRepository();
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || signupDisabled) {
throw new HttpError({
@@ -48,8 +48,10 @@ interface MockOAuth2Client {
let lastCreatedJWT: MockJWT | null = null;
let lastCreatedOAuth2Client: MockOAuth2Client | null = null;
vi.mock("@calcom/features/flags/server/utils", () => ({
getFeatureFlag: vi.fn().mockReturnValue(true),
vi.mock("@calcom/features/flags/features.repository", () => ({
FeaturesRepository: vi.fn().mockImplementation(() => ({
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true),
})),
}));
vi.mock("./getGoogleAppKeys", () => ({
+3 -3
View File
@@ -2,12 +2,11 @@ import { decodeHTML } from "entities";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
import { serverConfig } from "@calcom/lib/serverConfig";
import { setTestEmail } from "@calcom/lib/testEmails";
import prisma from "@calcom/prisma";
import { sanitizeDisplayName } from "../lib/sanitizeDisplayName";
@@ -30,7 +29,8 @@ export default class BaseEmail {
return {};
}
public async sendEmail() {
const emailsDisabled = await getFeatureFlag(prisma, "emails");
const featuresRepository = new FeaturesRepository();
const emailsDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("emails");
/** If email kill switch exists and is active, we prevent emails being sent. */
if (emailsDisabled) {
console.warn("Skipped Sending Email due to active Kill Switch");
+5 -3
View File
@@ -6,7 +6,7 @@ import {
sendEmailVerificationLink,
sendChangeOfEmailVerificationLink,
} from "@calcom/emails/email-manager";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -34,7 +34,8 @@ export const sendEmailVerification = async ({
}: VerifyEmailType) => {
const token = randomBytes(32).toString("hex");
const translation = await getTranslation(language ?? "en", "common");
const emailVerification = await getFeatureFlag(prisma, "email-verification");
const featuresRepository = new FeaturesRepository();
const emailVerification = await featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification");
if (!emailVerification) {
log.warn("Email verification is disabled - Skipping");
@@ -126,7 +127,8 @@ interface ChangeOfEmail {
export const sendChangeOfEmailVerification = async ({ user, language }: ChangeOfEmail) => {
const token = randomBytes(32).toString("hex");
const translation = await getTranslation(language ?? "en", "common");
const emailVerification = await getFeatureFlag(prisma, "email-verification");
const featuresRepository = new FeaturesRepository();
const emailVerification = await featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification");
if (!emailVerification) {
log.warn("Email verification is disabled - Skipping");
@@ -1,12 +1,13 @@
import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { MembershipRole } from "@calcom/prisma/client";
export const getServerSideProps = async ({ req }: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const organizationsEnabled = await getFeatureFlag(prisma, "organizations");
const featuresRepository = new FeaturesRepository();
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
// Check if organizations are enabled
if (!organizationsEnabled) {
return {
@@ -0,0 +1,328 @@
import { PrismaClient } from "@prisma/client";
import { describe, expect, beforeAll, afterAll, beforeEach, it } from "vitest";
import type { AppFlags } from "./config";
import { FeaturesRepository } from "./features.repository";
const prisma = new PrismaClient();
const featuresRepository = new FeaturesRepository();
// Access private clearCache method through type assertion
const clearCache = () => {
(featuresRepository as any).clearCache();
};
describe("FeaturesRepository Integration Tests", () => {
let testUser: { id: number };
let testTeam: { id: number };
const testFeature = "teams" as keyof AppFlags;
beforeAll(async () => {
// Create test user
testUser = await prisma.user.create({
data: {
email: `test-${Date.now()}@example.com`,
username: `test-${Date.now()}`,
},
});
// Create test team
testTeam = await prisma.team.create({
data: {
name: `Test Team ${Date.now()}`,
slug: `test-team-${Date.now()}`,
},
});
});
afterAll(async () => {
// Clean up test data in correct order to respect foreign key constraints
await prisma.userFeatures.deleteMany({
where: { userId: testUser.id },
});
await prisma.teamFeatures.deleteMany({
where: { teamId: testTeam.id },
});
await prisma.feature.deleteMany({
where: { slug: testFeature },
});
await prisma.membership.deleteMany({
where: { userId: testUser.id },
});
await prisma.team.deleteMany({
where: { id: testTeam.id },
});
await prisma.user.delete({
where: { id: testUser.id },
});
});
beforeEach(async () => {
// Reset feature state before each test
await prisma.feature.deleteMany({
where: { slug: testFeature },
});
await prisma.userFeatures.deleteMany({
where: { userId: testUser.id },
});
await prisma.teamFeatures.deleteMany({
where: { teamId: testTeam.id },
});
await prisma.membership.deleteMany({
where: { teamId: testTeam.id },
});
await prisma.team.deleteMany({
where: { id: testTeam.id },
});
// Recreate test team
testTeam = await prisma.team.create({
data: {
name: `Test Team ${Date.now()}`,
slug: `test-team-${Date.now()}`,
},
});
// Clear the feature flag cache
clearCache();
});
describe("checkIfFeatureIsEnabledGlobally", () => {
it("should return false when feature is not enabled globally", async () => {
// Verify database state
const dbFeature = await prisma.feature.findUnique({
where: { slug: testFeature },
});
expect(dbFeature).toBeNull();
// First call to initialize cache
await featuresRepository.checkIfFeatureIsEnabledGlobally(testFeature);
// Second call to get actual result
const result = await featuresRepository.checkIfFeatureIsEnabledGlobally(testFeature);
expect(result).toBe(false);
});
it("should return true when feature is enabled globally", async () => {
// Create the feature with enabled set to true
await prisma.feature.create({
data: {
slug: testFeature,
enabled: true,
type: "OPERATIONAL",
},
});
// Clear the feature flag cache
clearCache();
// Verify database state
const dbFeature = await prisma.feature.findUnique({
where: { slug: testFeature },
});
expect(dbFeature).not.toBeNull();
expect(dbFeature?.enabled).toBe(true);
// First call to initialize cache
await featuresRepository.checkIfFeatureIsEnabledGlobally(testFeature);
// Second call to get actual result
const result = await featuresRepository.checkIfFeatureIsEnabledGlobally(testFeature);
expect(result).toBe(true);
});
});
describe("checkIfUserHasFeature", () => {
it("should return false when user does not have feature", async () => {
const result = await featuresRepository.checkIfUserHasFeature(testUser.id, testFeature);
expect(result).toBe(false);
});
it("should return true when user has feature directly assigned", async () => {
await prisma.feature.create({
data: {
slug: testFeature,
enabled: true,
type: "OPERATIONAL",
},
});
await prisma.userFeatures.create({
data: {
userId: testUser.id,
featureId: testFeature,
assignedBy: "test",
},
});
const result = await featuresRepository.checkIfUserHasFeature(testUser.id, testFeature);
expect(result).toBe(true);
});
it("should return true when user belongs to team with feature", async () => {
await prisma.feature.create({
data: {
slug: testFeature,
enabled: true,
type: "OPERATIONAL",
},
});
await prisma.teamFeatures.create({
data: {
teamId: testTeam.id,
featureId: testFeature,
assignedBy: "test",
},
});
await prisma.membership.create({
data: {
teamId: testTeam.id,
userId: testUser.id,
role: "MEMBER",
accepted: true,
},
});
const result = await featuresRepository.checkIfUserHasFeature(testUser.id, testFeature);
expect(result).toBe(true);
});
it("should return true when user belongs to team where parent team has feature", async () => {
// Create an organization
const org = await prisma.team.create({
data: {
name: `Test Org ${Date.now()}`,
slug: `test-org-${Date.now()}`,
isOrganization: true,
},
});
// Make the test team part of the organization
await prisma.team.update({
where: { id: testTeam.id },
data: { parentId: org.id },
});
// Create the feature
await prisma.feature.create({
data: {
slug: testFeature,
enabled: true,
type: "OPERATIONAL",
},
});
// Clear the feature flag cache
clearCache();
// Assign feature to the organization
await prisma.teamFeatures.create({
data: {
teamId: org.id,
featureId: testFeature,
assignedBy: "test",
},
});
// Check if user is already a member of the team
const existingMembership = await prisma.membership.findFirst({
where: {
teamId: testTeam.id,
userId: testUser.id,
},
});
// Only create membership if it doesn't exist
if (!existingMembership) {
await prisma.membership.create({
data: {
teamId: testTeam.id,
userId: testUser.id,
role: "MEMBER",
accepted: true,
},
});
}
const result = await featuresRepository.checkIfUserHasFeature(testUser.id, testFeature);
expect(result).toBe(true);
// Clean up organization
await prisma.teamFeatures.deleteMany({
where: { teamId: org.id },
});
await prisma.team.delete({
where: { id: org.id },
});
});
});
describe("checkIfTeamHasFeature", () => {
it("should return false when team does not have feature", async () => {
const result = await featuresRepository.checkIfTeamHasFeature(testTeam.id, testFeature);
expect(result).toBe(false);
});
it("should return true when team has feature directly assigned", async () => {
await prisma.feature.create({
data: {
slug: testFeature,
enabled: true,
type: "OPERATIONAL",
},
});
await prisma.teamFeatures.create({
data: {
teamId: testTeam.id,
featureId: testFeature,
assignedBy: "test",
},
});
const result = await featuresRepository.checkIfTeamHasFeature(testTeam.id, testFeature);
expect(result).toBe(true);
});
it("should return true when parent team has feature", async () => {
const parentTeam = await prisma.team.create({
data: {
name: `Parent Team ${Date.now()}`,
slug: `parent-team-${Date.now()}`,
},
});
await prisma.team.update({
where: { id: testTeam.id },
data: { parentId: parentTeam.id },
});
await prisma.feature.create({
data: {
slug: testFeature,
enabled: true,
type: "OPERATIONAL",
},
});
await prisma.teamFeatures.create({
data: {
teamId: parentTeam.id,
featureId: testFeature,
assignedBy: "test",
},
});
const result = await featuresRepository.checkIfTeamHasFeature(testTeam.id, testFeature);
expect(result).toBe(true);
// Clean up parent team
await prisma.teamFeatures.deleteMany({
where: { teamId: parentTeam.id },
});
await prisma.team.delete({
where: { id: parentTeam.id },
});
});
});
});
+133 -44
View File
@@ -1,10 +1,14 @@
import { Prisma } from "@prisma/client";
import { captureException } from "@sentry/nextjs";
import db from "@calcom/prisma";
import type { AppFlags } from "./config";
import type { IFeaturesRepository } from "./features.repository.interface";
import { getFeatureFlag } from "./server/utils";
interface CacheOptions {
ttl: number; // time in ms
}
/**
* Repository class for managing feature flags and feature access control.
@@ -12,15 +16,62 @@ import { getFeatureFlag } from "./server/utils";
* for users, teams, and global application features.
*/
export class FeaturesRepository implements IFeaturesRepository {
private static featuresCache: { data: any[]; expiry: number } | null = null;
private clearCache() {
FeaturesRepository.featuresCache = null;
}
/**
* Gets all features with their enabled status.
* Uses caching to avoid hitting the database on every request.
* @returns Promise<Feature[]> - Array of all features
*/
public async getAllFeatures() {
if (FeaturesRepository.featuresCache && Date.now() < FeaturesRepository.featuresCache.expiry) {
return FeaturesRepository.featuresCache.data;
}
const features = await db.feature.findMany({
orderBy: { slug: "asc" },
cacheStrategy: { swr: 300, ttl: 300 },
});
FeaturesRepository.featuresCache = {
data: features,
expiry: Date.now() + 5 * 60 * 1000, // 5 minutes cache
};
return features;
}
/**
* Gets a map of all feature flags and their enabled status.
* Uses caching to avoid hitting the database on every request.
* @returns Promise<AppFlags> - A map of feature flags to their enabled status
*/
public async getFeatureFlagMap() {
const flags = await this.getAllFeatures();
return flags.reduce((acc, flag) => {
acc[flag.slug as keyof AppFlags] = flag.enabled;
return acc;
}, {} as AppFlags);
}
/**
* Checks if a feature is enabled globally in the application.
* @param slug - The feature flag identifier to check
* @returns Promise<boolean> - True if the feature is enabled globally, false otherwise
* @throws Error if the feature flag check fails
*/
async checkIfFeatureIsEnabledGlobally(slug: keyof AppFlags) {
async checkIfFeatureIsEnabledGlobally(
slug: keyof AppFlags,
_options: CacheOptions = { ttl: 5 * 60 * 1000 }
): Promise<boolean> {
try {
return await getFeatureFlag(db, slug);
const features = await this.getAllFeatures();
const flag = features.find((f) => f.slug === slug);
return Boolean(flag && flag.enabled);
} catch (err) {
captureException(err);
throw err;
@@ -70,25 +121,38 @@ export class FeaturesRepository implements IFeaturesRepository {
*/
private async checkIfUserBelongsToTeamWithFeature(userId: number, slug: string) {
try {
const user = await db.user.findUnique({
where: {
id: userId,
teams: {
some: {
team: {
features: {
some: {
featureId: slug,
},
},
},
},
},
},
select: { id: true },
});
if (user) return true;
return false;
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}
) 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}
) 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 db.$queryRaw<unknown[]>(query);
return result.length > 0;
} catch (err) {
captureException(err);
throw err;
@@ -96,36 +160,61 @@ export class FeaturesRepository implements IFeaturesRepository {
}
/**
* Checks if a team has access to a specific feature.
* Also checks if the team's parent organization has the feature.
* @param teamId - The ID of the team to check
* Checks if a team or any of its ancestors has access to a specific feature.
* Uses a recursive CTE raw SQL query for performance.
* @param teamId - The ID of the team to start the check from
* @param featureId - The feature identifier to check
* @returns Promise<boolean> - True if the team has access to the feature, false otherwise
* @throws Error if the team feature check fails
* @returns Promise<boolean> - True if the team or any ancestor has the feature, false otherwise
* @throws Error if the database query fails
*/
async checkIfTeamHasFeature(teamId: number, featureId: keyof AppFlags) {
async checkIfTeamHasFeature(teamId: number, featureId: keyof AppFlags): Promise<boolean> {
try {
const teamFeature = await db.teamFeatures.findFirst({
// Early return if team has feature directly assigned
const teamHasFeature = await db.teamFeatures.findFirst({
where: {
OR: [
{ teamId, featureId },
{
team: {
parent: {
features: {
some: {
featureId,
},
},
},
},
},
],
teamId,
featureId,
},
});
return !!teamFeature;
if (teamHasFeature) return true;
const query = Prisma.sql`
WITH RECURSIVE TeamHierarchy AS (
-- Start with the initial team
SELECT id, "parentId",
CASE WHEN EXISTS (
SELECT 1 FROM "TeamFeatures" tf
WHERE tf."teamId" = id AND tf."featureId" = ${featureId}
) THEN true ELSE false END as has_feature
FROM "Team"
WHERE id = ${teamId}
UNION ALL
-- Recursively get parent teams
SELECT p.id, p."parentId",
CASE WHEN EXISTS (
SELECT 1 FROM "TeamFeatures" tf
WHERE tf."teamId" = p.id AND tf."featureId" = ${featureId}
) 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 db.$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;
}
}
@@ -1,8 +1,12 @@
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
import { getFeatureFlagMap } from "../utils";
import { FeaturesRepository } from "../../features.repository";
export const map = publicProcedure.query(async ({ ctx }) => {
const { prisma } = ctx;
return getFeatureFlagMap(prisma);
/**
* TRPC procedure that returns a map of all feature flags and their enabled status.
* Uses the FeaturesRepository to handle caching and database access.
*/
export const map = publicProcedure.query(async () => {
const featuresRepository = new FeaturesRepository();
return featuresRepository.getFeatureFlagMap();
});
+4 -6
View File
@@ -1,15 +1,13 @@
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
import { router } from "@calcom/trpc/server/trpc";
import { FeaturesRepository } from "../features.repository";
import { map } from "./procedures/map";
export const featureFlagRouter = router({
list: publicProcedure.query(async ({ ctx }) => {
const { prisma } = ctx;
return prisma.feature.findMany({
orderBy: { slug: "asc" },
cacheStrategy: { swr: 300, ttl: 300 },
});
list: publicProcedure.query(async () => {
const featuresRepository = new FeaturesRepository();
return featuresRepository.getAllFeatures();
}),
map,
});
-72
View File
@@ -1,72 +0,0 @@
import type { PrismaClient } from "@calcom/prisma";
import type { AppFlags } from "../config";
// This is a temporary cache to avoid hitting the database on every lambda invocation
let TEMP_CACHE: AppFlags | null = null;
export async function getFeatureFlagMap(prisma: PrismaClient) {
// If we've already fetched the flags, return them
if (TEMP_CACHE) return TEMP_CACHE;
const flags = await prisma.feature.findMany({
orderBy: { slug: "asc" },
cacheStrategy: { swr: 300, ttl: 300 },
});
TEMP_CACHE = flags.reduce((acc, flag) => {
acc[flag.slug as keyof AppFlags] = flag.enabled;
return acc;
}, {} as AppFlags);
return TEMP_CACHE;
}
interface CacheEntry {
value: boolean; // adapt to other supported value types in the future
expiry: number;
}
interface CacheOptions {
ttl: number; // time in ms
}
const featureFlagCache = new Map<keyof AppFlags, CacheEntry>();
const isExpired = (entry: CacheEntry): boolean => {
return Date.now() > entry.expiry;
};
export const getFeatureFlag = async (
prisma: PrismaClient,
slug: keyof AppFlags,
options: CacheOptions = { ttl: 5 * 60 * 1000 }
): Promise<boolean> => {
// pre-compute all app flags, each one will independelty reload it's own state after expiry.
if (featureFlagCache.size === 0) {
const flags = await prisma.feature.findMany({ orderBy: { slug: "asc" } });
flags.forEach((flag) => {
featureFlagCache.set(flag.slug as keyof AppFlags, {
value: flag.enabled,
expiry: Date.now() + options.ttl,
});
});
}
const cacheEntry = featureFlagCache.get(slug);
if (cacheEntry && !isExpired(cacheEntry)) {
return cacheEntry.value;
}
const flag = await prisma.feature.findUnique({
where: {
slug,
},
});
const isEnabled = Boolean(flag && flag.enabled);
const expiry = Date.now() + options.ttl;
featureFlagCache.set(slug, { value: isEnabled, expiry });
return isEnabled;
};
@@ -6,7 +6,7 @@ import type { GetServerSidePropsContext, NextApiResponse } from "next";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { sendChangeOfEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
@@ -40,7 +40,8 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
const billingService = new StripeBillingService();
const userMetadata = handleUserMetadata({ ctx, input });
const locale = input.locale || user.locale;
const emailVerification = await getFeatureFlag(prisma, "email-verification");
const featuresRepository = new FeaturesRepository();
const emailVerification = await featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification");
const { travelSchedules, ...rest } = input;
@@ -19,12 +19,11 @@ vi.mock("@calcom/features/redis/RedisService", () => {
};
});
vi.mock("@calcom/features/flags/server/utils", () => {
// Mock kill switch to be false
return {
getFeatureFlag: vi.fn().mockResolvedValue(false),
};
});
vi.mock("@calcom/features/flags/features.repository", () => ({
FeaturesRepository: vi.fn().mockImplementation(() => ({
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(false),
})),
}));
vi.spyOn(CalcomEmails, "sendOrganizationAdminNoSlotsNotification");