From 4b99072bcc01e7a252df828db71248a202cb65d1 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Wed, 14 Jan 2026 10:21:56 +0100 Subject: [PATCH] feat: add scope configuration for feature opt-in (#26801) * feat: add scope configuration for feature opt-in Add scope field to OptInFeatureConfig that allows features to be scoped to specific levels (org, team, user). This enables features to be shown only at certain settings pages rather than all three. Changes: - Add OptInFeatureScope type with values 'org', 'team', 'user' - Add optional scope field to OptInFeatureConfig interface - Add getOptInFeaturesForScope helper function to filter features by scope - Update FeatureOptInService to filter features based on scope - Update tRPC router to pass scope parameter for org/team endpoints Features without a scope field default to all scopes for backward compatibility. Co-Authored-By: eunjae@cal.com * feat: add scope validation to setUserFeatureState and setTeamFeatureState - Add isFeatureAllowedForScope helper function to check if a feature is allowed for a scope - Update setUserFeatureState to reject if feature is not scoped to 'user' - Update setTeamFeatureState to accept scope parameter and reject if feature is not allowed - Update tRPC router to pass scope parameter for team and org endpoints - Fix unit test mock to include new config exports Co-Authored-By: eunjae@cal.com * feat: use ErrorWithCode for scope validation and add tests - Replace raw Error with ErrorWithCode using ErrorCode.BadRequest - Add comprehensive tests for setUserFeatureState scope validation - Add comprehensive tests for setTeamFeatureState scope validation - Test both enabled/disabled and inherit state scenarios - Test error messages include feature ID and scope name Co-Authored-By: eunjae@cal.com * feat: improve Features menu visibility to use scope configuration Co-Authored-By: eunjae@cal.com * refactor: remove unused hasOptInFeaturesForScope function Co-Authored-By: eunjae@cal.com * fix: allow features not in config at all scopes (permissive default) Co-Authored-By: eunjae@cal.com * fix: revert permissive default and mock scope validation in integration tests Co-Authored-By: eunjae@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../SettingsLayoutAppDirClient.tsx | 12 +- packages/features/feature-opt-in/config.ts | 36 ++- .../FeatureOptInService.integration-test.ts | 12 +- .../services/FeatureOptInService.test.ts | 241 +++++++++++++++++- .../services/FeatureOptInService.ts | 54 +++- .../services/IFeatureOptInService.ts | 21 +- packages/features/feature-opt-in/types.ts | 9 + .../routers/viewer/featureOptIn/_router.ts | 7 +- 8 files changed, 356 insertions(+), 36 deletions(-) diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx index eb1b325a8a..61b40b91c0 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx @@ -11,7 +11,11 @@ import React, { useEffect, useState, useMemo } from "react"; import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import type { OrganizationBranding } from "@calcom/features/ee/organizations/context/provider"; -import { HAS_OPT_IN_FEATURES } from "@calcom/features/feature-opt-in/config"; +import { + HAS_ORG_OPT_IN_FEATURES, + HAS_TEAM_OPT_IN_FEATURES, + HAS_USER_OPT_IN_FEATURES, +} from "@calcom/features/feature-opt-in/config"; import type { TeamFeatures } from "@calcom/features/flags/config"; import { useIsFeatureEnabledForTeam } from "@calcom/features/flags/hooks/useIsFeatureEnabledForTeam"; import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants"; @@ -74,7 +78,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { href: "/settings/my-account/push-notifications", trackingMetadata: { section: "my_account", page: "push_notifications" }, }, - ...(HAS_OPT_IN_FEATURES + ...(HAS_USER_OPT_IN_FEATURES ? [ { name: "features", @@ -200,7 +204,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { isExternalLink: true, trackingMetadata: { section: "organization", page: "admin_api" }, }, - ...(HAS_OPT_IN_FEATURES + ...(HAS_ORG_OPT_IN_FEATURES ? [ { name: "features", @@ -656,7 +660,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record - {HAS_OPT_IN_FEATURES && ( + {HAS_TEAM_OPT_IN_FEATURES && ( 0; + +/** Whether there are opt-in features available for the user scope */ +export const HAS_USER_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.some((f) => !f.scope || f.scope.includes("user")); + +/** Whether there are opt-in features available for the team scope */ +export const HAS_TEAM_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.some((f) => !f.scope || f.scope.includes("team")); + +/** Whether there are opt-in features available for the org scope */ +export const HAS_ORG_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.some((f) => !f.scope || f.scope.includes("org")); + +/** + * Get opt-in features that are available for a specific scope. + * Features without a scope field are available for all scopes. + */ +export function getOptInFeaturesForScope(scope: OptInFeatureScope): OptInFeatureConfig[] { + return OPT_IN_FEATURES.filter((f) => !f.scope || f.scope.includes(scope)); +} + +/** + * Check if a feature is allowed for a specific scope. + * Features without a scope field are allowed for all scopes. + * Features not in the config are NOT allowed (must be explicitly configured). + */ +export function isFeatureAllowedForScope(slug: string, scope: OptInFeatureScope): boolean { + const config = getOptInFeatureConfig(slug); + if (!config) return false; + return !config.scope || config.scope.includes(scope); +} diff --git a/packages/features/feature-opt-in/services/FeatureOptInService.integration-test.ts b/packages/features/feature-opt-in/services/FeatureOptInService.integration-test.ts index f0b33c5ecc..fed1c101ea 100644 --- a/packages/features/feature-opt-in/services/FeatureOptInService.integration-test.ts +++ b/packages/features/feature-opt-in/services/FeatureOptInService.integration-test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { getFeatureOptInService } from "@calcom/features/di/containers/FeatureOptInService"; import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository"; @@ -8,6 +8,16 @@ import { prisma } from "@calcom/prisma"; import type { IFeatureOptInService } from "./IFeatureOptInService"; +// Mock isFeatureAllowedForScope to always return true for integration tests. +// The scope validation logic is tested in unit tests; integration tests focus on database behavior. +vi.mock("../config", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isFeatureAllowedForScope: () => true, + }; +}); + // Helper to generate unique identifiers per test const uniqueId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; diff --git a/packages/features/feature-opt-in/services/FeatureOptInService.test.ts b/packages/features/feature-opt-in/services/FeatureOptInService.test.ts index 73fddf2d71..a5e0ee489f 100644 --- a/packages/features/feature-opt-in/services/FeatureOptInService.test.ts +++ b/packages/features/feature-opt-in/services/FeatureOptInService.test.ts @@ -2,30 +2,49 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { FeatureState } from "@calcom/features/flags/config"; import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; import { FeatureOptInService } from "./FeatureOptInService"; -// Mock the OPT_IN_FEATURES config -vi.mock("../config", () => ({ - OPT_IN_FEATURES: [ +const mockIsFeatureAllowedForScope = vi.fn(); + +vi.mock("../config", () => { + const mockFeatures = [ { slug: "test-feature-1", titleI18nKey: "test_feature_1", descriptionI18nKey: "test_feature_1_desc" }, { slug: "test-feature-2", titleI18nKey: "test_feature_2", descriptionI18nKey: "test_feature_2_desc" }, - ], -})); + { slug: "org-only-feature", titleI18nKey: "org_only", descriptionI18nKey: "org_only_desc", scope: ["org"] }, + { slug: "team-only-feature", titleI18nKey: "team_only", descriptionI18nKey: "team_only_desc", scope: ["team"] }, + { slug: "user-only-feature", titleI18nKey: "user_only", descriptionI18nKey: "user_only_desc", scope: ["user"] }, + ]; + return { + OPT_IN_FEATURES: mockFeatures, + getOptInFeaturesForScope: () => mockFeatures, + get isFeatureAllowedForScope() { + return mockIsFeatureAllowedForScope; + }, + getOptInFeatureConfig: (slug: string) => mockFeatures.find((f) => f.slug === slug), + }; +}); describe("FeatureOptInService", () => { let mockFeaturesRepository: { getAllFeatures: ReturnType; getTeamsFeatureStates: ReturnType; + setUserFeatureState: ReturnType; + setTeamFeatureState: ReturnType; }; let service: FeatureOptInService; beforeEach(() => { vi.resetAllMocks(); + mockIsFeatureAllowedForScope.mockReturnValue(true); mockFeaturesRepository = { getAllFeatures: vi.fn(), getTeamsFeatureStates: vi.fn(), + setUserFeatureState: vi.fn(), + setTeamFeatureState: vi.fn(), }; service = new FeatureOptInService(mockFeaturesRepository as unknown as FeaturesRepository); @@ -62,7 +81,7 @@ describe("FeatureOptInService", () => { // Verify that only the team ID was queried (no parent org) expect(mockFeaturesRepository.getTeamsFeatureStates).toHaveBeenCalledWith({ teamIds: [1], - featureIds: ["test-feature-1", "test-feature-2"], + featureIds: ["test-feature-1", "test-feature-2", "org-only-feature", "team-only-feature", "user-only-feature"], }); }); @@ -97,7 +116,7 @@ describe("FeatureOptInService", () => { // Verify that both team ID and parent org ID were queried expect(mockFeaturesRepository.getTeamsFeatureStates).toHaveBeenCalledWith({ teamIds: [1, 100], - featureIds: ["test-feature-1", "test-feature-2"], + featureIds: ["test-feature-1", "test-feature-2", "org-only-feature", "team-only-feature", "user-only-feature"], }); }); @@ -176,8 +195,214 @@ describe("FeatureOptInService", () => { // Verify that only the team ID was queried (no parent org) expect(mockFeaturesRepository.getTeamsFeatureStates).toHaveBeenCalledWith({ teamIds: [1], - featureIds: ["test-feature-1", "test-feature-2"], + featureIds: ["test-feature-1", "test-feature-2", "org-only-feature", "team-only-feature", "user-only-feature"], }); }); }); + + describe("setUserFeatureState", () => { + it("should set user feature state when scope allows", async () => { + mockIsFeatureAllowedForScope.mockReturnValue(true); + mockFeaturesRepository.setUserFeatureState.mockResolvedValue(undefined); + + await service.setUserFeatureState({ + userId: 1, + featureId: "test-feature-1", + state: "enabled", + assignedBy: 2, + }); + + expect(mockFeaturesRepository.setUserFeatureState).toHaveBeenCalledWith({ + userId: 1, + featureId: "test-feature-1", + state: "enabled", + assignedBy: "user-2", + }); + }); + + it("should set user feature state to inherit when scope allows", async () => { + mockIsFeatureAllowedForScope.mockReturnValue(true); + mockFeaturesRepository.setUserFeatureState.mockResolvedValue(undefined); + + await service.setUserFeatureState({ + userId: 1, + featureId: "test-feature-1", + state: "inherit", + }); + + expect(mockFeaturesRepository.setUserFeatureState).toHaveBeenCalledWith({ + userId: 1, + featureId: "test-feature-1", + state: "inherit", + }); + }); + + it("should throw ErrorWithCode when feature is not allowed at user scope", async () => { + mockIsFeatureAllowedForScope.mockReturnValue(false); + + await expect( + service.setUserFeatureState({ + userId: 1, + featureId: "org-only-feature", + state: "enabled", + assignedBy: 2, + }) + ).rejects.toThrow(ErrorWithCode); + + await expect( + service.setUserFeatureState({ + userId: 1, + featureId: "org-only-feature", + state: "enabled", + assignedBy: 2, + }) + ).rejects.toMatchObject({ + code: ErrorCode.BadRequest, + message: 'Feature "org-only-feature" is not available at the user scope', + }); + + expect(mockFeaturesRepository.setUserFeatureState).not.toHaveBeenCalled(); + }); + + it("should validate scope before setting inherit state", async () => { + mockIsFeatureAllowedForScope.mockReturnValue(false); + + await expect( + service.setUserFeatureState({ + userId: 1, + featureId: "team-only-feature", + state: "inherit", + }) + ).rejects.toThrow(ErrorWithCode); + + expect(mockFeaturesRepository.setUserFeatureState).not.toHaveBeenCalled(); + }); + }); + + describe("setTeamFeatureState", () => { + it("should set team feature state when scope allows", async () => { + mockIsFeatureAllowedForScope.mockReturnValue(true); + mockFeaturesRepository.setTeamFeatureState.mockResolvedValue(undefined); + + await service.setTeamFeatureState({ + teamId: 1, + featureId: "test-feature-1", + state: "enabled", + assignedBy: 2, + scope: "team", + }); + + expect(mockFeaturesRepository.setTeamFeatureState).toHaveBeenCalledWith({ + teamId: 1, + featureId: "test-feature-1", + state: "enabled", + assignedBy: "user-2", + }); + }); + + it("should set team feature state to inherit when scope allows", async () => { + mockIsFeatureAllowedForScope.mockReturnValue(true); + mockFeaturesRepository.setTeamFeatureState.mockResolvedValue(undefined); + + await service.setTeamFeatureState({ + teamId: 1, + featureId: "test-feature-1", + state: "inherit", + scope: "team", + }); + + expect(mockFeaturesRepository.setTeamFeatureState).toHaveBeenCalledWith({ + teamId: 1, + featureId: "test-feature-1", + state: "inherit", + }); + }); + + it("should throw ErrorWithCode when feature is not allowed at team scope", async () => { + mockIsFeatureAllowedForScope.mockReturnValue(false); + + await expect( + service.setTeamFeatureState({ + teamId: 1, + featureId: "user-only-feature", + state: "enabled", + assignedBy: 2, + scope: "team", + }) + ).rejects.toThrow(ErrorWithCode); + + await expect( + service.setTeamFeatureState({ + teamId: 1, + featureId: "user-only-feature", + state: "enabled", + assignedBy: 2, + scope: "team", + }) + ).rejects.toMatchObject({ + code: ErrorCode.BadRequest, + message: 'Feature "user-only-feature" is not available at the team scope', + }); + + expect(mockFeaturesRepository.setTeamFeatureState).not.toHaveBeenCalled(); + }); + + it("should throw ErrorWithCode when feature is not allowed at org scope", async () => { + mockIsFeatureAllowedForScope.mockReturnValue(false); + + await expect( + service.setTeamFeatureState({ + teamId: 100, + featureId: "team-only-feature", + state: "enabled", + assignedBy: 2, + scope: "org", + }) + ).rejects.toThrow(ErrorWithCode); + + await expect( + service.setTeamFeatureState({ + teamId: 100, + featureId: "team-only-feature", + state: "enabled", + assignedBy: 2, + scope: "org", + }) + ).rejects.toMatchObject({ + code: ErrorCode.BadRequest, + message: 'Feature "team-only-feature" is not available at the org scope', + }); + + expect(mockFeaturesRepository.setTeamFeatureState).not.toHaveBeenCalled(); + }); + + it("should default to team scope when scope is not provided", async () => { + mockIsFeatureAllowedForScope.mockReturnValue(true); + mockFeaturesRepository.setTeamFeatureState.mockResolvedValue(undefined); + + await service.setTeamFeatureState({ + teamId: 1, + featureId: "test-feature-1", + state: "enabled", + assignedBy: 2, + }); + + expect(mockIsFeatureAllowedForScope).toHaveBeenCalledWith("test-feature-1", "team"); + }); + + it("should validate scope before setting inherit state", async () => { + mockIsFeatureAllowedForScope.mockReturnValue(false); + + await expect( + service.setTeamFeatureState({ + teamId: 1, + featureId: "user-only-feature", + state: "inherit", + scope: "team", + }) + ).rejects.toThrow(ErrorWithCode); + + expect(mockFeaturesRepository.setTeamFeatureState).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/features/feature-opt-in/services/FeatureOptInService.ts b/packages/features/feature-opt-in/services/FeatureOptInService.ts index 6587c6700d..67d714a069 100644 --- a/packages/features/feature-opt-in/services/FeatureOptInService.ts +++ b/packages/features/feature-opt-in/services/FeatureOptInService.ts @@ -1,11 +1,17 @@ import type { FeatureId, FeatureState } from "@calcom/features/flags/config"; import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; -import type { OptInFeaturePolicy } from "../types"; -import { getOptInFeatureConfig, OPT_IN_FEATURES } from "../config"; +import { getOptInFeatureConfig, getOptInFeaturesForScope, isFeatureAllowedForScope } from "../config"; import { applyAutoOptIn } from "../lib/applyAutoOptIn"; import { computeEffectiveStateAcrossTeams } from "../lib/computeEffectiveState"; -import type { EffectiveStateReason, IFeatureOptInService, ResolvedFeatureState } from "./IFeatureOptInService"; +import type { OptInFeaturePolicy, OptInFeatureScope } from "../types"; +import type { + EffectiveStateReason, + IFeatureOptInService, + ResolvedFeatureState, +} from "./IFeatureOptInService"; type ListFeaturesForUserResult = { featureId: FeatureId; @@ -180,7 +186,7 @@ export class FeatureOptInService implements IFeatureOptInService { /** * List all opt-in features with their states for a user across teams. - * Only returns features that are in the allowlist and globally enabled. + * Only returns features that are in the allowlist, globally enabled, and scoped to "user". */ async listFeaturesForUser(input: { userId: number; @@ -188,7 +194,8 @@ export class FeatureOptInService implements IFeatureOptInService { teamIds: number[]; }): Promise { const { userId, orgId, teamIds } = input; - const featureIds = OPT_IN_FEATURES.map((config) => config.slug); + const userScopedFeatures = getOptInFeaturesForScope("user"); + const featureIds = userScopedFeatures.map((config) => config.slug); const resolvedStates = await this.resolveFeatureStatesAcrossTeams({ userId, @@ -201,27 +208,29 @@ export class FeatureOptInService implements IFeatureOptInService { } /** - * List all opt-in features with their raw states for a team. - * Used for team admin settings page to configure feature opt-in. - * Only returns features that are in the allowlist and globally enabled. + * List all opt-in features with their raw states for a team or organization. + * Used for team/org admin settings page to configure feature opt-in. + * Only returns features that are in the allowlist, globally enabled, and scoped to the specified scope. * If parentOrgId is provided, also returns the organization state for each feature. */ async listFeaturesForTeam(input: { teamId: number; parentOrgId?: number | null; + scope?: OptInFeatureScope; }): Promise { - const { teamId, parentOrgId } = input; + const { teamId, parentOrgId, scope = "team" } = input; const teamIdsToQuery = getTeamIdsToQuery(teamId, parentOrgId); + const scopedFeatures = getOptInFeaturesForScope(scope); const [allFeatures, teamStates] = await Promise.all([ this.featuresRepository.getAllFeatures(), this.featuresRepository.getTeamsFeatureStates({ teamIds: teamIdsToQuery, - featureIds: OPT_IN_FEATURES.map((config) => config.slug), + featureIds: scopedFeatures.map((config) => config.slug), }), ]); - const results = OPT_IN_FEATURES.map((config) => { + const results = scopedFeatures.map((config) => { const globalFeature = allFeatures.find((f) => f.slug === config.slug); const globalEnabled = globalFeature?.enabled ?? false; const teamState = teamStates[config.slug]?.[teamId] ?? "inherit"; @@ -236,6 +245,7 @@ export class FeatureOptInService implements IFeatureOptInService { /** * Set user's feature state. * Delegates to FeaturesRepository.setUserFeatureState. + * Throws an error if the feature is not scoped to "user". */ async setUserFeatureState( input: @@ -243,6 +253,14 @@ export class FeatureOptInService implements IFeatureOptInService { | { userId: number; featureId: FeatureId; state: "inherit" } ): Promise { const { userId, featureId, state } = input; + + if (!isFeatureAllowedForScope(featureId, "user")) { + throw new ErrorWithCode( + ErrorCode.BadRequest, + `Feature "${featureId}" is not available at the user scope` + ); + } + if (state === "inherit") { await this.featuresRepository.setUserFeatureState({ userId, featureId, state }); } else { @@ -259,13 +277,23 @@ export class FeatureOptInService implements IFeatureOptInService { /** * Set team's feature state. * Delegates to FeaturesRepository.setTeamFeatureState. + * Throws an error if the feature is not scoped to the specified scope. */ async setTeamFeatureState( input: - | { teamId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number } - | { teamId: number; featureId: FeatureId; state: "inherit" } + | { teamId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number; scope?: OptInFeatureScope } + | { teamId: number; featureId: FeatureId; state: "inherit"; scope?: OptInFeatureScope } ): Promise { const { teamId, featureId, state } = input; + const scope = input.scope ?? "team"; + + if (!isFeatureAllowedForScope(featureId, scope)) { + throw new ErrorWithCode( + ErrorCode.BadRequest, + `Feature "${featureId}" is not available at the ${scope} scope` + ); + } + if (state === "inherit") { await this.featuresRepository.setTeamFeatureState({ teamId, featureId, state }); } else { diff --git a/packages/features/feature-opt-in/services/IFeatureOptInService.ts b/packages/features/feature-opt-in/services/IFeatureOptInService.ts index 57115b1e50..e75b8f08f5 100644 --- a/packages/features/feature-opt-in/services/IFeatureOptInService.ts +++ b/packages/features/feature-opt-in/services/IFeatureOptInService.ts @@ -1,6 +1,7 @@ import type { FeatureId, FeatureState } from "@calcom/features/flags/config"; import type { EffectiveStateReason } from "../lib/computeEffectiveState"; +import type { OptInFeatureScope } from "../types"; export type { EffectiveStateReason }; @@ -24,12 +25,18 @@ export interface IFeatureOptInService { teamIds: number[]; featureIds: FeatureId[]; }): Promise>; - listFeaturesForUser(input: { userId: number; orgId: number | null; teamIds: number[] }): Promise< - ResolvedFeatureState[] + listFeaturesForUser(input: { + userId: number; + orgId: number | null; + teamIds: number[]; + }): Promise; + listFeaturesForTeam(input: { + teamId: number; + parentOrgId?: number | null; + scope?: OptInFeatureScope; + }): Promise< + { featureId: FeatureId; globalEnabled: boolean; teamState: FeatureState; orgState: FeatureState }[] >; - listFeaturesForTeam( - input: { teamId: number; parentOrgId?: number | null } - ): Promise<{ featureId: FeatureId; globalEnabled: boolean; teamState: FeatureState; orgState: FeatureState }[]>; setUserFeatureState( input: | { userId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number } @@ -37,7 +44,7 @@ export interface IFeatureOptInService { ): Promise; setTeamFeatureState( input: - | { teamId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number } - | { teamId: number; featureId: FeatureId; state: "inherit" } + | { teamId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number; scope?: OptInFeatureScope } + | { teamId: number; featureId: FeatureId; state: "inherit"; scope?: OptInFeatureScope } ): Promise; } diff --git a/packages/features/feature-opt-in/types.ts b/packages/features/feature-opt-in/types.ts index 702b61623e..0e4d7aafcc 100644 --- a/packages/features/feature-opt-in/types.ts +++ b/packages/features/feature-opt-in/types.ts @@ -11,6 +11,15 @@ import type { FeatureState } from "@calcom/features/flags/config"; */ export type OptInFeaturePolicy = "permissive" | "strict"; +/** + * Scope that determines at which levels a feature can be configured. + * + * - `org`: Feature can be configured at the organization level + * - `team`: Feature can be configured at the team level + * - `user`: Feature can be configured at the user level + */ +export type OptInFeatureScope = "org" | "team" | "user"; + /** * Normalized feature representation used across all scopes (user, team, org). */ diff --git a/packages/trpc/server/routers/viewer/featureOptIn/_router.ts b/packages/trpc/server/routers/viewer/featureOptIn/_router.ts index a7ee117516..36154f40ab 100644 --- a/packages/trpc/server/routers/viewer/featureOptIn/_router.ts +++ b/packages/trpc/server/routers/viewer/featureOptIn/_router.ts @@ -69,7 +69,7 @@ export const featureOptInRouter = router({ const parentOrg = await teamRepository.findParentOrganizationByTeamId(input.teamId); const parentOrgId = parentOrg?.id ?? null; - return featureOptInService.listFeaturesForTeam({ teamId: input.teamId, parentOrgId }); + return featureOptInService.listFeaturesForTeam({ teamId: input.teamId, parentOrgId, scope: "team" }); }), /** @@ -78,7 +78,8 @@ export const featureOptInRouter = router({ */ listForOrganization: createOrgPbacProcedure("featureOptIn.read").query(async ({ ctx }) => { // Organizations use the same listFeaturesForTeam since they're stored in TeamFeatures - return featureOptInService.listFeaturesForTeam({ teamId: ctx.organizationId }); + // Pass scope: "org" to filter features that are scoped to organizations + return featureOptInService.listFeaturesForTeam({ teamId: ctx.organizationId, scope: "org" }); }), /** @@ -132,6 +133,7 @@ export const featureOptInRouter = router({ featureId: input.slug, state: input.state, assignedBy: ctx.user.id, + scope: "team", }); return { success: true }; @@ -161,6 +163,7 @@ export const featureOptInRouter = router({ featureId: input.slug, state: input.state, assignedBy: ctx.user.id, + scope: "org", }); return { success: true };