refactor: centralize feature flag operations in FeaturesRepository (#21032)
This commit is contained in:
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 {
|
||||
|
||||
@@ -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,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", () => ({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in new issue
Block a user