diff --git a/apps/web/app/(use-page-wrapper)/insights/page.tsx b/apps/web/app/(use-page-wrapper)/insights/page.tsx index 6d5ccdf6f5..1ce981f4b0 100644 --- a/apps/web/app/(use-page-wrapper)/insights/page.tsx +++ b/apps/web/app/(use-page-wrapper)/insights/page.tsx @@ -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(); diff --git a/apps/web/app/(use-page-wrapper)/insights/router-position/page.tsx b/apps/web/app/(use-page-wrapper)/insights/router-position/page.tsx index 77025dcfc3..b7a216fe1e 100644 --- a/apps/web/app/(use-page-wrapper)/insights/router-position/page.tsx +++ b/apps/web/app/(use-page-wrapper)/insights/router-position/page.tsx @@ -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(); diff --git a/apps/web/app/(use-page-wrapper)/insights/routing/page.tsx b/apps/web/app/(use-page-wrapper)/insights/routing/page.tsx index c94d1d6916..1758503051 100644 --- a/apps/web/app/(use-page-wrapper)/insights/routing/page.tsx +++ b/apps/web/app/(use-page-wrapper)/insights/routing/page.tsx @@ -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(); diff --git a/apps/web/lib/settings/organizations/new/getServerSideProps.tsx b/apps/web/lib/settings/organizations/new/getServerSideProps.tsx index a9ce4fa951..8e90d95a98 100644 --- a/apps/web/lib/settings/organizations/new/getServerSideProps.tsx +++ b/apps/web/lib/settings/organizations/new/getServerSideProps.tsx @@ -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> => { - const organizations = await getFeatureFlag(prisma, "organizations"); + const featuresRepository = new FeaturesRepository(); + const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations"); // Check if organizations are enabled if (!organizations) { return { diff --git a/apps/web/lib/signup/getServerSideProps.tsx b/apps/web/lib/signup/getServerSideProps.tsx index 5329ce60e8..9645f69c14 100644 --- a/apps/web/lib/signup/getServerSideProps.tsx +++ b/apps/web/lib/signup/getServerSideProps.tsx @@ -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 diff --git a/apps/web/lib/team/[slug]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/getServerSideProps.tsx index 62d2763cfc..45815f29ab 100644 --- a/apps/web/lib/team/[slug]/getServerSideProps.tsx +++ b/apps/web/lib/team/[slug]/getServerSideProps.tsx @@ -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, diff --git a/apps/web/pages/api/auth/signup.ts b/apps/web/pages/api/auth/signup.ts index fde16c45fe..891faf4e95 100644 --- a/apps/web/pages/api/auth/signup.ts +++ b/apps/web/pages/api/auth/signup.ts @@ -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({ diff --git a/packages/app-store/googlecalendar/lib/CalendarService.test.ts b/packages/app-store/googlecalendar/lib/CalendarService.test.ts index 23d5003719..d1f555fbaf 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.test.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.test.ts @@ -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", () => ({ diff --git a/packages/emails/templates/_base-email.ts b/packages/emails/templates/_base-email.ts index d2150c0856..4a7c10d0ba 100644 --- a/packages/emails/templates/_base-email.ts +++ b/packages/emails/templates/_base-email.ts @@ -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"); diff --git a/packages/features/auth/lib/verifyEmail.ts b/packages/features/auth/lib/verifyEmail.ts index b4fed47951..3e7fde3ea9 100644 --- a/packages/features/auth/lib/verifyEmail.ts +++ b/packages/features/auth/lib/verifyEmail.ts @@ -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"); diff --git a/packages/features/ee/organizations/pages/organization.tsx b/packages/features/ee/organizations/pages/organization.tsx index ec94fd560f..5f9f0ae76f 100644 --- a/packages/features/ee/organizations/pages/organization.tsx +++ b/packages/features/ee/organizations/pages/organization.tsx @@ -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 { diff --git a/packages/features/flags/features.repository.integration-test.ts b/packages/features/flags/features.repository.integration-test.ts new file mode 100644 index 0000000000..8bc1bf985c --- /dev/null +++ b/packages/features/flags/features.repository.integration-test.ts @@ -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 }, + }); + }); + }); +}); diff --git a/packages/features/flags/features.repository.ts b/packages/features/flags/features.repository.ts index 1e6bd0626a..149c0b6359 100644 --- a/packages/features/flags/features.repository.ts +++ b/packages/features/flags/features.repository.ts @@ -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 - 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 - 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 - 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 { 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(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 - True if the team has access to the feature, false otherwise - * @throws Error if the team feature check fails + * @returns Promise - 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 { 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(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; } } diff --git a/packages/features/flags/server/procedures/map.ts b/packages/features/flags/server/procedures/map.ts index fdd23e0d7e..d5fe9170b3 100644 --- a/packages/features/flags/server/procedures/map.ts +++ b/packages/features/flags/server/procedures/map.ts @@ -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(); }); diff --git a/packages/features/flags/server/router.ts b/packages/features/flags/server/router.ts index 9a151cbf62..6cc0459c77 100644 --- a/packages/features/flags/server/router.ts +++ b/packages/features/flags/server/router.ts @@ -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, }); diff --git a/packages/features/flags/server/utils.ts b/packages/features/flags/server/utils.ts deleted file mode 100644 index b666defa55..0000000000 --- a/packages/features/flags/server/utils.ts +++ /dev/null @@ -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(); - -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 => { - // 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; -}; diff --git a/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts b/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts index 9a3609773b..c01a5b9ab2 100644 --- a/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts +++ b/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots.test.ts b/packages/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots.test.ts index 42039e5815..01f5fd010a 100644 --- a/packages/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots.test.ts +++ b/packages/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots.test.ts @@ -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");