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 eb1b325a8a3d27..61b40b91c03629 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 f0b33c5ecc2835..fed1c101eac2f2 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 73fddf2d719a88..a5e0ee489f525a 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 6587c6700d5eaa..c37cb0f6c17e46 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,
@@ -197,31 +204,33 @@ export class FeatureOptInService implements IFeatureOptInService {
featureIds,
});
- return featureIds.map((featureId) => resolvedStates[featureId]).filter((state) => state.globalEnabled);
+ return featureIds.map((featureId) => resolvedStates[featureId]).filter((state) => !state.globalEnabled);
}
/**
- * 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,7 +253,15 @@ export class FeatureOptInService implements IFeatureOptInService {
| { userId: number; featureId: FeatureId; state: "inherit" }
): Promise {
const { userId, featureId, state } = input;
- if (state === "inherit") {
+
+ 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 {
const { assignedBy } = input;
@@ -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 57115b1e509149..e75b8f08f59f95 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 702b61623eb6ac..0e4d7aafcc185c 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 a7ee1175161f8a..57ee18a1d0dfaf 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" });
}),
/**
@@ -99,7 +100,7 @@ export const featureOptInRouter = router({
});
}
- await featureOptInService.setUserFeatureState({
+ featureOptInService.setUserFeatureState({
userId: ctx.user.id,
featureId: input.slug,
state: input.state,
@@ -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 };