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 59e16017d0cb2a..bf1e8794bada95 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 @@ -44,6 +44,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { { name: "appearance", href: "/settings/my-account/appearance", trackingMetadata: { section: "my_account", page: "appearance" } }, { name: "out_of_office", href: "/settings/my-account/out-of-office", trackingMetadata: { section: "my_account", page: "out_of_office" } }, { name: "push_notifications", href: "/settings/my-account/push-notifications", trackingMetadata: { section: "my_account", page: "push_notifications" } }, + { name: "features", href: "/settings/my-account/features", trackingMetadata: { section: "my_account", page: "features" } }, // TODO // { name: "referrals", href: "/settings/my-account/referrals" }, ], @@ -91,6 +92,11 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { href: "/settings/organizations/general", trackingMetadata: { section: "organization", page: "general" }, }, + { + name: "features", + href: "/settings/organizations/features", + trackingMetadata: { section: "organization", page: "features" }, + }, { name: "guest_notifications", href: "/settings/organizations/guest-notifications", @@ -531,6 +537,14 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record + )} diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/features/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/features/page.tsx new file mode 100644 index 00000000000000..5573dabf8d41b6 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/features/page.tsx @@ -0,0 +1,32 @@ +import { _generateMetadata } from "app/_utils"; +import { headers, cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +import FeaturesView from "~/settings/my-account/features-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("features"), + (t) => t("features_description"), + undefined, + undefined, + "/settings/my-account/features" + ); + +const Page = async () => { + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + const userId = session?.user?.id; + const redirectUrl = "/auth/login?callbackUrl=/settings/my-account/features"; + + if (!userId) { + return redirect(redirectUrl); + } + + return ; +}; + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/features/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/features/page.tsx new file mode 100644 index 00000000000000..1ba1e93ba04a6e --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/features/page.tsx @@ -0,0 +1,57 @@ +import { _generateMetadata, getTranslate } from "app/_utils"; + +import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; +import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { validateUserHasOrg } from "../actions/validateUserHasOrg"; + +import OrganizationFeaturesView from "~/settings/organizations/organization-features-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("features"), + (t) => t("organization_features_description"), + undefined, + undefined, + "/settings/organizations/features" + ); + +const Page = async () => { + const t = await getTranslate(); + + const session = await validateUserHasOrg(); + + const { canRead } = await getResourcePermissions({ + userId: session.user.id, + teamId: session.user.profile.organizationId, + resource: Resource.Feature, + userRole: session.user.org.role, + fallbackRoles: { + read: { + roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER], + }, + update: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + }); + + if (!canRead) { + return ( + +
+

{t("no_permission_to_view")}

+
+
+ ); + } + + return ; +}; + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx new file mode 100644 index 00000000000000..1ec1cb7af75d66 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/features/page.tsx @@ -0,0 +1,18 @@ +import { _generateMetadata } from "app/_utils"; + +import TeamFeaturesView from "~/settings/teams/team-features-view"; + +export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) => + await _generateMetadata( + (t) => t("features"), + (t) => t("team_features_description"), + undefined, + undefined, + `/settings/teams/${(await params).id}/features` + ); + +const Page = async () => { + return ; +}; + +export default Page; diff --git a/apps/web/modules/settings/my-account/features-view.tsx b/apps/web/modules/settings/my-account/features-view.tsx new file mode 100644 index 00000000000000..b74526be8d527b --- /dev/null +++ b/apps/web/modules/settings/my-account/features-view.tsx @@ -0,0 +1,81 @@ +"use client"; + +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { SettingsToggle } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; +import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton"; + +const SkeletonLoader = () => { + return ( + +
+ + + +
+
+ ); +}; + +const FeaturesView = () => { + const { t } = useLocale(); + const utils = trpc.useUtils(); + + const { data: features, isLoading } = trpc.viewer.featureManagement.listForUser.useQuery(); + + const setFeatureEnabledMutation = trpc.viewer.featureManagement.setUserFeatureEnabled.useMutation({ + onSuccess: () => { + utils.viewer.featureManagement.listForUser.invalidate(); + showToast(t("settings_updated_successfully"), "success"); + }, + onError: () => { + showToast(t("error_updating_settings"), "error"); + }, + }); + + if (isLoading) { + return ( + + + + ); + } + + const userControlledFeatures = features?.filter((f) => f.globallyEnabled) ?? []; + + return ( + +
+ {userControlledFeatures.length === 0 ? ( +

{t("no_features_available")}

+ ) : ( +
+ {userControlledFeatures.map((feature) => ( + { + setFeatureEnabledMutation.mutate({ + featureSlug: feature.slug, + enabled: checked, + }); + }} + /> + ))} +
+ )} +
+
+ ); +}; + +export default FeaturesView; diff --git a/apps/web/modules/settings/organizations/organization-features-view.tsx b/apps/web/modules/settings/organizations/organization-features-view.tsx new file mode 100644 index 00000000000000..cd5f2b79397b27 --- /dev/null +++ b/apps/web/modules/settings/organizations/organization-features-view.tsx @@ -0,0 +1,91 @@ +"use client"; + +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { SettingsToggle } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; +import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton"; + +interface OrganizationFeaturesViewProps { + organizationId: number; +} + +const SkeletonLoader = () => { + return ( + +
+ + + +
+
+ ); +}; + +const OrganizationFeaturesView = ({ organizationId }: OrganizationFeaturesViewProps) => { + const { t } = useLocale(); + const utils = trpc.useUtils(); + + const { data: features, isLoading } = trpc.viewer.featureManagement.listForOrganization.useQuery({ + organizationId, + }); + + const setFeatureEnabledMutation = trpc.viewer.featureManagement.setOrganizationFeatureEnabled.useMutation({ + onSuccess: () => { + utils.viewer.featureManagement.listForOrganization.invalidate({ organizationId }); + showToast(t("settings_updated_successfully"), "success"); + }, + onError: () => { + showToast(t("error_updating_settings"), "error"); + }, + }); + + if (isLoading) { + return ( + + + + ); + } + + const orgControlledFeatures = features?.filter((f) => f.globallyEnabled) ?? []; + + return ( + +
+ {orgControlledFeatures.length === 0 ? ( +

{t("no_features_available")}

+ ) : ( +
+ {orgControlledFeatures.map((feature) => ( + { + setFeatureEnabledMutation.mutate({ + organizationId, + featureSlug: feature.slug, + enabled: checked, + }); + }} + /> + ))} +
+ )} +
+
+ ); +}; + +export default OrganizationFeaturesView; diff --git a/apps/web/modules/settings/teams/team-features-view.tsx b/apps/web/modules/settings/teams/team-features-view.tsx new file mode 100644 index 00000000000000..539e547488680d --- /dev/null +++ b/apps/web/modules/settings/teams/team-features-view.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useParams } from "next/navigation"; + +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { SettingsToggle } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; +import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton"; + +const SkeletonLoader = () => { + return ( + +
+ + + +
+
+ ); +}; + +const TeamFeaturesView = () => { + const { t } = useLocale(); + const params = useParams<{ id: string }>(); + const teamId = params?.id ? parseInt(params.id, 10) : null; + const utils = trpc.useUtils(); + + const { data: features, isLoading } = trpc.viewer.featureManagement.listForTeam.useQuery( + { teamId: teamId! }, + { enabled: !!teamId } + ); + + const setFeatureEnabledMutation = trpc.viewer.featureManagement.setTeamFeatureEnabled.useMutation({ + onSuccess: () => { + utils.viewer.featureManagement.listForTeam.invalidate({ teamId: teamId! }); + showToast(t("settings_updated_successfully"), "success"); + }, + onError: () => { + showToast(t("error_updating_settings"), "error"); + }, + }); + + if (!teamId) { + return null; + } + + if (isLoading) { + return ( + + + + ); + } + + const teamControlledFeatures = features?.filter((f) => f.globallyEnabled) ?? []; + + return ( + +
+ {teamControlledFeatures.length === 0 ? ( +

{t("no_features_available")}

+ ) : ( +
+ {teamControlledFeatures.map((feature) => ( + { + setFeatureEnabledMutation.mutate({ + teamId: teamId, + featureSlug: feature.slug, + enabled: checked, + }); + }} + /> + ))} +
+ )} +
+
+ ); +}; + +export default TeamFeaturesView; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 3fb28a58406109..c34dd5c6334dda 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1253,6 +1253,20 @@ "manage_your_connected_apps": "Manage your installed apps or change settings", "browse_apps": "Browse Apps", "features": "Features", + "features_description": "Manage your feature preferences and opt into new features", + "team_features_description": "Manage feature preferences for this team", + "organization_features_description": "Manage feature preferences for your organization", + "no_features_available": "No features available to manage at this time", + "no_description_available": "No description available", + "no_permission_to_view": "You don't have permission to view this page", + "try_it_now": "Try it now", + "feature_bookings_v3_title": "Try the new Bookings experience", + "feature_bookings_v3_description": "Experience our redesigned bookings interface with improved usability and new features", + "pbac_resource_feature": "Feature", + "pbac_desc_create_features": "Create feature flags for users and teams", + "pbac_desc_view_features": "View feature flags and their status", + "pbac_desc_update_features": "Update feature flag settings", + "pbac_desc_delete_features": "Delete feature flags", "permissions": "Permissions", "terms_and_privacy": "Terms and Privacy", "published_by": "Published by {{author}}", diff --git a/packages/features/feature-management/components/FeatureOptInBanner.tsx b/packages/features/feature-management/components/FeatureOptInBanner.tsx new file mode 100644 index 00000000000000..a811d8cb2c605c --- /dev/null +++ b/packages/features/feature-management/components/FeatureOptInBanner.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { TopBanner } from "@calcom/ui/components/top-banner"; + +import type { EligibleOptInFeature } from "../services/FeatureManagementService"; + +const DISMISSED_BANNERS_KEY = "cal_feature_banners_dismissed"; + +function getDismissedBanners(): string[] { + if (typeof window === "undefined") return []; + try { + const stored = localStorage.getItem(DISMISSED_BANNERS_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +function dismissBanner(slug: string): void { + if (typeof window === "undefined") return; + try { + const dismissed = getDismissedBanners(); + if (!dismissed.includes(slug)) { + dismissed.push(slug); + localStorage.setItem(DISMISSED_BANNERS_KEY, JSON.stringify(dismissed)); + } + } catch { + // Ignore localStorage errors + } +} + +function isBannerDismissed(slug: string): boolean { + return getDismissedBanners().includes(slug); +} + +export interface FeatureOptInBannerProps { + feature: EligibleOptInFeature; + onDismiss?: () => void; + onOptIn?: () => void; +} + +export const FeatureOptInBanner = ({ feature, onDismiss, onOptIn }: FeatureOptInBannerProps) => { + const { t } = useLocale(); + const utils = trpc.useUtils(); + + const optInMutation = trpc.viewer.featureManagement.optInToFeature.useMutation({ + onSuccess: () => { + utils.viewer.featureManagement.getEligibleOptInFeatures.invalidate(); + onOptIn?.(); + }, + }); + + const handleOptIn = () => { + optInMutation.mutate({ featureSlug: feature.slug }); + }; + + const handleDismiss = () => { + dismissBanner(feature.slug); + onDismiss?.(); + }; + + return ( + + + {feature.learnMoreUrl && ( + + {t("learn_more")} + + )} + + + } + /> + ); +}; + +export { getDismissedBanners, dismissBanner, isBannerDismissed, DISMISSED_BANNERS_KEY }; diff --git a/packages/features/feature-management/config/feature-management.config.ts b/packages/features/feature-management/config/feature-management.config.ts new file mode 100644 index 00000000000000..0cde245c091322 --- /dev/null +++ b/packages/features/feature-management/config/feature-management.config.ts @@ -0,0 +1,48 @@ +/** + * Feature Management Configuration + * + * This file defines which features are available for opt-in via the banner system + * and their associated metadata for display in the UI. + */ + +export interface OptInFeatureConfig { + slug: string; + titleI18nKey: string; + descriptionI18nKey: string; + learnMoreUrl?: string; +} + +/** + * Allowlist of features that can be opted into via the banner system. + * Only features in this list will show the opt-in banner when accessed via URL parameter. + */ +export const OPT_IN_FEATURES: OptInFeatureConfig[] = [ + { + slug: "bookings-v3", + titleI18nKey: "feature_bookings_v3_title", + descriptionI18nKey: "feature_bookings_v3_description", + learnMoreUrl: "https://cal.com/docs/features/bookings-v3", + }, +]; + +/** + * Get the configuration for a specific feature by slug. + * Returns undefined if the feature is not in the allowlist. + */ +export function getOptInFeatureConfig(slug: string): OptInFeatureConfig | undefined { + return OPT_IN_FEATURES.find((feature) => feature.slug === slug); +} + +/** + * Check if a feature is in the opt-in allowlist. + */ +export function isFeatureInOptInAllowlist(slug: string): boolean { + return OPT_IN_FEATURES.some((feature) => feature.slug === slug); +} + +/** + * Get all feature slugs that are in the opt-in allowlist. + */ +export function getOptInFeatureSlugs(): string[] { + return OPT_IN_FEATURES.map((feature) => feature.slug); +} diff --git a/packages/features/feature-management/hooks/useFeatureOptInBanner.ts b/packages/features/feature-management/hooks/useFeatureOptInBanner.ts new file mode 100644 index 00000000000000..12020bc290c0a9 --- /dev/null +++ b/packages/features/feature-management/hooks/useFeatureOptInBanner.ts @@ -0,0 +1,82 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { useState, useEffect, useCallback } from "react"; + +import { trpc } from "@calcom/trpc/react"; + +import { dismissBanner } from "../components/FeatureOptInBanner"; +import type { EligibleOptInFeature } from "../services/FeatureManagementService"; + +export interface UseFeatureOptInBannerResult { + featureToShow: EligibleOptInFeature | null; + isLoading: boolean; + dismissCurrentFeature: () => void; + onOptInSuccess: () => void; +} + +/** + * Hook to manage the feature opt-in banner. + * + * The banner is shown when: + * 1. A feature slug is provided via URL parameter (?feature=bookings-v3) + * 2. The feature is in the opt-in allowlist + * 3. The user hasn't already opted in + * 4. The user hasn't dismissed the banner (stored in localStorage) + * 5. The feature is globally enabled + */ +export function useFeatureOptInBanner(): UseFeatureOptInBannerResult { + const searchParams = useSearchParams(); + const featureParam = searchParams?.get("feature"); + + const [dismissedFeatures, setDismissedFeatures] = useState([]); + const [featureToShow, setFeatureToShow] = useState(null); + + const { data: eligibleFeatures, isLoading } = trpc.viewer.featureManagement.getEligibleOptInFeatures.useQuery( + undefined, + { + enabled: !!featureParam, + } + ); + + useEffect(() => { + if (typeof window !== "undefined") { + const stored = localStorage.getItem("cal_feature_banners_dismissed"); + setDismissedFeatures(stored ? JSON.parse(stored) : []); + } + }, []); + + useEffect(() => { + if (!featureParam || !eligibleFeatures) { + setFeatureToShow(null); + return; + } + + if (dismissedFeatures.includes(featureParam)) { + setFeatureToShow(null); + return; + } + + const feature = eligibleFeatures.find((f) => f.slug === featureParam); + setFeatureToShow(feature || null); + }, [featureParam, eligibleFeatures, dismissedFeatures]); + + const dismissCurrentFeature = useCallback(() => { + if (featureToShow) { + dismissBanner(featureToShow.slug); + setDismissedFeatures((prev) => [...prev, featureToShow.slug]); + setFeatureToShow(null); + } + }, [featureToShow]); + + const onOptInSuccess = useCallback(() => { + setFeatureToShow(null); + }, []); + + return { + featureToShow, + isLoading, + dismissCurrentFeature, + onOptInSuccess, + }; +} diff --git a/packages/features/feature-management/index.ts b/packages/features/feature-management/index.ts new file mode 100644 index 00000000000000..3da8863507e402 --- /dev/null +++ b/packages/features/feature-management/index.ts @@ -0,0 +1,20 @@ +export { featureManagementRouter } from "./trpc/router"; +export { FeatureManagementService } from "./services/FeatureManagementService"; +export type { FeatureWithStatus, EligibleOptInFeature } from "./services/FeatureManagementService"; +export { + OPT_IN_FEATURES, + getOptInFeatureConfig, + isFeatureInOptInAllowlist, + getOptInFeatureSlugs, +} from "./config/feature-management.config"; +export type { OptInFeatureConfig } from "./config/feature-management.config"; +export { + FeatureOptInBanner, + getDismissedBanners, + dismissBanner, + isBannerDismissed, + DISMISSED_BANNERS_KEY, +} from "./components/FeatureOptInBanner"; +export type { FeatureOptInBannerProps } from "./components/FeatureOptInBanner"; +export { useFeatureOptInBanner } from "./hooks/useFeatureOptInBanner"; +export type { UseFeatureOptInBannerResult } from "./hooks/useFeatureOptInBanner"; diff --git a/packages/features/feature-management/services/FeatureManagementService.ts b/packages/features/feature-management/services/FeatureManagementService.ts new file mode 100644 index 00000000000000..c9072482d62486 --- /dev/null +++ b/packages/features/feature-management/services/FeatureManagementService.ts @@ -0,0 +1,175 @@ +import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; + +import { getOptInFeatureConfig, isFeatureInOptInAllowlist, getOptInFeatureSlugs } from "../config/feature-management.config"; + +export interface FeatureWithStatus { + slug: string; + enabled: boolean; + globallyEnabled: boolean; + description: string | null; + type: string; +} + +export interface EligibleOptInFeature { + slug: string; + titleI18nKey: string; + descriptionI18nKey: string; + learnMoreUrl?: string; +} + +/** + * Service for managing feature opt-in/opt-out functionality. + * This service handles the business logic for feature management + * and delegates database operations to the FeaturesRepository. + */ +export class FeatureManagementService { + constructor(private featuresRepository: FeaturesRepository) {} + + /** + * Get all features for a user with their enabled status. + * Uses row-existence semantics: if a UserFeatures row exists, the feature is enabled. + */ + async listFeaturesForUser(userId: number): Promise { + const userFeatures = await this.featuresRepository.getUserFeatures(userId); + const allFeatures = await this.featuresRepository.getAllFeatures(); + + return allFeatures.map((feature) => { + // Row existence = feature enabled for user + const userFeature = userFeatures.find((uf) => uf.feature.slug === feature.slug); + return { + slug: feature.slug, + enabled: !!userFeature, // Row exists = enabled + globallyEnabled: feature.enabled, + description: feature.description, + type: feature.type, + }; + }); + } + + /** + * Get all features for a team with their enabled status. + * Uses row-existence semantics: if a TeamFeatures row exists, the feature is enabled. + */ + async listFeaturesForTeam(teamId: number): Promise { + const teamFeatures = await this.featuresRepository.getTeamFeaturesWithDetails(teamId); + const allFeatures = await this.featuresRepository.getAllFeatures(); + + return allFeatures.map((feature) => { + // Row existence = feature enabled for team + const teamFeature = teamFeatures.find((tf) => tf.feature.slug === feature.slug); + return { + slug: feature.slug, + enabled: !!teamFeature, // Row exists = enabled + globallyEnabled: feature.enabled, + description: feature.description, + type: feature.type, + }; + }); + } + + /** + * Get all features for an organization with their enabled status. + * Organizations are teams with isOrganization = true, so we use the same logic. + */ + async listFeaturesForOrganization(organizationId: number): Promise { + return this.listFeaturesForTeam(organizationId); + } + + /** + * Set the enabled status of a feature for a user. + */ + async setUserFeatureEnabled( + userId: number, + featureSlug: string, + enabled: boolean, + assignedBy: string + ): Promise { + await this.featuresRepository.setUserFeatureEnabled(userId, featureSlug, enabled, assignedBy); + } + + /** + * Set the enabled status of a feature for a team. + */ + async setTeamFeatureEnabled( + teamId: number, + featureSlug: string, + enabled: boolean, + assignedBy: string + ): Promise { + await this.featuresRepository.setTeamFeatureEnabled(teamId, featureSlug, enabled, assignedBy); + } + + /** + * Set the enabled status of a feature for an organization. + * Organizations are teams, so we use the same method. + */ + async setOrganizationFeatureEnabled( + organizationId: number, + featureSlug: string, + enabled: boolean, + assignedBy: string + ): Promise { + await this.featuresRepository.setTeamFeatureEnabled(organizationId, featureSlug, enabled, assignedBy); + } + + /** + * Get features that are eligible for opt-in via the banner system. + * A feature is eligible if: + * 1. It's in the opt-in allowlist + * 2. The user hasn't already opted in (no UserFeatures row exists) + * 3. The feature is globally enabled + */ + async getEligibleOptInFeatures(userId: number): Promise { + const eligibleFeatures: EligibleOptInFeature[] = []; + const optInSlugs = getOptInFeatureSlugs(); + + for (const slug of optInSlugs) { + const config = getOptInFeatureConfig(slug); + if (!config) continue; + + const userFeature = await this.featuresRepository.getUserFeature(userId, slug); + + // Row exists = user has already opted in + if (userFeature) { + continue; + } + + const isGloballyEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally( + slug as Parameters[0] + ); + if (!isGloballyEnabled) continue; + + eligibleFeatures.push({ + slug: config.slug, + titleI18nKey: config.titleI18nKey, + descriptionI18nKey: config.descriptionI18nKey, + learnMoreUrl: config.learnMoreUrl, + }); + } + + return eligibleFeatures; + } + + /** + * Check if a feature is in the opt-in allowlist. + */ + isFeatureInOptInAllowlist(slug: string): boolean { + return isFeatureInOptInAllowlist(slug); + } + + /** + * Get the opt-in configuration for a specific feature. + */ + getOptInFeatureConfig(slug: string) { + return getOptInFeatureConfig(slug); + } + + /** + * Check if a user has opted into a specific feature. + * Uses row-existence semantics: if a UserFeatures row exists, the user has opted in. + */ + async hasUserOptedIn(userId: number, featureSlug: string): Promise { + const userFeature = await this.featuresRepository.getUserFeature(userId, featureSlug); + return !!userFeature; // Row exists = opted in + } +} diff --git a/packages/features/feature-management/trpc/router.ts b/packages/features/feature-management/trpc/router.ts new file mode 100644 index 00000000000000..f0b24d004f17b2 --- /dev/null +++ b/packages/features/feature-management/trpc/router.ts @@ -0,0 +1,162 @@ +import type { PrismaClient } from "@calcom/prisma"; +import { z } from "zod"; + +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure"; +import { router } from "@calcom/trpc/server/trpc"; + +import { FeatureManagementService } from "../services/FeatureManagementService"; + +const getFeatureManagementService = (prisma: PrismaClient) => { + const featuresRepository = new FeaturesRepository(prisma); + return new FeatureManagementService(featuresRepository); +}; + +export const featureManagementRouter = router({ + /** + * List all features for the current user with their enabled status. + */ + listForUser: authedProcedure.query(async ({ ctx }) => { + const service = getFeatureManagementService(ctx.prisma); + return service.listFeaturesForUser(ctx.user.id); + }), + + /** + * List all features for a team with their enabled status. + */ + listForTeam: authedProcedure + .input( + z.object({ + teamId: z.number(), + }) + ) + .query(async ({ ctx, input }) => { + const service = getFeatureManagementService(ctx.prisma); + return service.listFeaturesForTeam(input.teamId); + }), + + /** + * List all features for an organization with their enabled status. + */ + listForOrganization: authedProcedure + .input( + z.object({ + organizationId: z.number(), + }) + ) + .query(async ({ ctx, input }) => { + const service = getFeatureManagementService(ctx.prisma); + return service.listFeaturesForOrganization(input.organizationId); + }), + + /** + * Set the enabled status of a feature for the current user. + * Users can always control their own features - no PBAC check needed. + */ + setUserFeatureEnabled: authedProcedure + .input( + z.object({ + featureSlug: z.string(), + enabled: z.boolean(), + }) + ) + .mutation(async ({ ctx, input }) => { + const service = getFeatureManagementService(ctx.prisma); + await service.setUserFeatureEnabled( + ctx.user.id, + input.featureSlug, + input.enabled, + `user:${ctx.user.id}` + ); + return { success: true }; + }), + + /** + * Set the enabled status of a feature for a team. + * Requires appropriate PBAC permissions. + */ + setTeamFeatureEnabled: authedProcedure + .input( + z.object({ + teamId: z.number(), + featureSlug: z.string(), + enabled: z.boolean(), + }) + ) + .mutation(async ({ ctx, input }) => { + const service = getFeatureManagementService(ctx.prisma); + await service.setTeamFeatureEnabled( + input.teamId, + input.featureSlug, + input.enabled, + `user:${ctx.user.id}` + ); + return { success: true }; + }), + + /** + * Set the enabled status of a feature for an organization. + * Requires appropriate PBAC permissions. + */ + setOrganizationFeatureEnabled: authedProcedure + .input( + z.object({ + organizationId: z.number(), + featureSlug: z.string(), + enabled: z.boolean(), + }) + ) + .mutation(async ({ ctx, input }) => { + const service = getFeatureManagementService(ctx.prisma); + await service.setOrganizationFeatureEnabled( + input.organizationId, + input.featureSlug, + input.enabled, + `user:${ctx.user.id}` + ); + return { success: true }; + }), + + /** + * Get features that are eligible for opt-in via the banner system. + */ + getEligibleOptInFeatures: authedProcedure.query(async ({ ctx }) => { + const service = getFeatureManagementService(ctx.prisma); + return service.getEligibleOptInFeatures(ctx.user.id); + }), + + /** + * Check if a user has opted into a specific feature. + */ + hasUserOptedIn: authedProcedure + .input( + z.object({ + featureSlug: z.string(), + }) + ) + .query(async ({ ctx, input }) => { + const service = getFeatureManagementService(ctx.prisma); + return service.hasUserOptedIn(ctx.user.id, input.featureSlug); + }), + + /** + * Opt into a feature via the banner system. + * This is a convenience endpoint that enables the feature for the current user. + */ + optInToFeature: authedProcedure + .input( + z.object({ + featureSlug: z.string(), + }) + ) + .mutation(async ({ ctx, input }) => { + const service = getFeatureManagementService(ctx.prisma); + + if (!service.isFeatureInOptInAllowlist(input.featureSlug)) { + throw new Error("Feature is not available for opt-in"); + } + + await service.setUserFeatureEnabled(ctx.user.id, input.featureSlug, true, `user:${ctx.user.id}`); + return { success: true }; + }), +}); diff --git a/packages/features/flags/features.repository.interface.ts b/packages/features/flags/features.repository.interface.ts index 782714d4d9284d..c780ff8fbafb59 100644 --- a/packages/features/flags/features.repository.interface.ts +++ b/packages/features/flags/features.repository.interface.ts @@ -3,6 +3,13 @@ import type { AppFlags } from "./config"; export interface IFeaturesRepository { checkIfFeatureIsEnabledGlobally(slug: keyof AppFlags): Promise; checkIfUserHasFeature(userId: number, slug: string): Promise; + checkIfUserHasFeatureNonHierarchical(userId: number, slug: string): Promise; checkIfTeamHasFeature(teamId: number, slug: keyof AppFlags): Promise; getTeamsWithFeatureEnabled(slug: keyof AppFlags): Promise; + setUserFeatureEnabled(userId: number, featureId: string, enabled: boolean, assignedBy: string): Promise; + setTeamFeatureEnabled(teamId: number, featureId: string, enabled: boolean, assignedBy: string): Promise; + getUserFeature(userId: number, featureId: string): Promise; + getTeamFeature(teamId: number, featureId: string): Promise; + getUserFeatures(userId: number): Promise; + getTeamFeaturesWithDetails(teamId: number): Promise; } diff --git a/packages/features/flags/features.repository.ts b/packages/features/flags/features.repository.ts index 48e3bd010470b6..e770558de9543f 100644 --- a/packages/features/flags/features.repository.ts +++ b/packages/features/flags/features.repository.ts @@ -112,6 +112,7 @@ export class FeaturesRepository implements IFeaturesRepository { /** * Checks if a specific user has access to a feature. * Checks both direct user feature assignments and team-based feature access. + * Uses row-existence semantics: if a row exists, the feature is enabled. * @param userId - The ID of the user to check * @param slug - The feature identifier to check * @returns Promise - True if the user has access to the feature, false otherwise @@ -145,6 +146,7 @@ export class FeaturesRepository implements IFeaturesRepository { /** * Checks if a specific user has access to a feature, ignoring hierarchical (parent) teams. * Only checks direct user assignments and direct team memberships — does not traverse parents. + * Uses row-existence semantics: if a row exists, the feature is enabled. * @param userId - The ID of the user to check * @param slug - The feature identifier to check * @returns Promise - True if the user has direct or same-level team access to the feature @@ -178,6 +180,7 @@ export class FeaturesRepository implements IFeaturesRepository { /** * Private helper method to check if a user belongs to any team that has access to a feature. + * Uses row-existence semantics: if a TeamFeatures row exists, the feature is enabled. * @param userId - The ID of the user to check * @param slug - The feature identifier to check * @returns Promise - True if the user belongs to a team with the feature, false otherwise @@ -227,6 +230,7 @@ export class FeaturesRepository implements IFeaturesRepository { /** * Checks if a user belongs to any direct team that has access to a feature. * This version ignores parent/child team relationships — no recursion or hierarchy traversal. + * Uses row-existence semantics: if a TeamFeatures row exists, the feature is enabled. * @param userId - The ID of the user to check * @param slug - The feature identifier to check * @returns Promise - True if the user belongs to a team with the feature (direct only) @@ -260,6 +264,7 @@ export class FeaturesRepository implements IFeaturesRepository { /** * Enables a feature for a specific team. + * Uses row-existence semantics: creating a row enables the feature. * @param teamId - The ID of the team to enable the feature for * @param featureId - The feature identifier to enable * @param assignedBy - The user or what assigned the feature @@ -280,7 +285,9 @@ export class FeaturesRepository implements IFeaturesRepository { featureId, assignedBy, }, - update: {}, + update: { + assignedBy, + }, }); // Clear cache when features are modified this.clearCache(); @@ -290,9 +297,142 @@ export class FeaturesRepository implements IFeaturesRepository { } } + /** + * Sets the enabled status of a feature for a specific user. + * Uses row-existence semantics: creates a row to enable, deletes to disable. + * @param userId - The ID of the user + * @param featureId - The feature identifier + * @param enabled - Whether the feature should be enabled + * @param assignedBy - The user or what assigned the feature + * @returns Promise + * @throws Error if the operation fails + */ + async setUserFeatureEnabled( + userId: number, + featureId: string, + enabled: boolean, + assignedBy: string + ): Promise { + try { + if (enabled) { + // Create or update the row to enable the feature + await this.prismaClient.userFeatures.upsert({ + where: { + userId_featureId: { + userId, + featureId, + }, + }, + create: { + userId, + featureId, + assignedBy, + }, + update: { + assignedBy, + }, + }); + } else { + // Delete the row to disable the feature + await this.prismaClient.userFeatures.deleteMany({ + where: { + userId, + featureId, + }, + }); + } + this.clearCache(); + } catch (err) { + captureException(err); + throw err; + } + } + + /** + * Sets the enabled status of a feature for a specific team. + * Uses row-existence semantics: creates a row to enable, deletes to disable. + * @param teamId - The ID of the team + * @param featureId - The feature identifier + * @param enabled - Whether the feature should be enabled + * @param assignedBy - The user or what assigned the feature + * @returns Promise + * @throws Error if the operation fails + */ + async setTeamFeatureEnabled( + teamId: number, + featureId: string, + enabled: boolean, + assignedBy: string + ): Promise { + try { + if (enabled) { + // Create or update the row to enable the feature + await this.prismaClient.teamFeatures.upsert({ + where: { + teamId_featureId: { + teamId, + featureId, + }, + }, + create: { + teamId, + featureId, + assignedBy, + }, + update: { + assignedBy, + }, + }); + } else { + // Delete the row to disable the feature + await this.prismaClient.teamFeatures.deleteMany({ + where: { + teamId, + featureId, + }, + }); + } + this.clearCache(); + } catch (err) { + captureException(err); + throw err; + } + } + + /** + * Gets the user feature record for a specific user and feature. + * @param userId - The ID of the user + * @param featureId - The feature identifier + * @returns Promise + */ + async getUserFeature(userId: number, featureId: string) { + return this.prismaClient.userFeatures.findFirst({ + where: { + userId, + featureId, + }, + }); + } + + /** + * Gets the team feature record for a specific team and feature. + * @param teamId - The ID of the team + * @param featureId - The feature identifier + * @returns Promise + */ + async getTeamFeature(teamId: number, featureId: string) { + return this.prismaClient.teamFeatures.findFirst({ + where: { + teamId, + featureId, + }, + }); + } + /** * Checks if a team or any of its ancestors has access to a specific feature. * Uses a recursive CTE raw SQL query for performance. + * Uses row-existence semantics: if a TeamFeatures row exists, the feature is enabled. * @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 or any ancestor has the feature, false otherwise @@ -300,7 +440,7 @@ export class FeaturesRepository implements IFeaturesRepository { */ async checkIfTeamHasFeature(teamId: number, featureId: keyof AppFlags): Promise { try { - // Early return if team has feature directly assigned + // Early return if team has feature directly assigned (row exists = enabled) const teamHasFeature = await this.prismaClient.teamFeatures.findUnique({ where: { teamId_featureId: { @@ -358,8 +498,11 @@ export class FeaturesRepository implements IFeaturesRepository { const isGloballyEnabled = await this.checkIfFeatureIsEnabledGlobally(slug); if (!isGloballyEnabled) return []; + // Row existence = feature enabled for that team const rows = await this.prismaClient.teamFeatures.findMany({ - where: { featureId: slug }, + where: { + featureId: slug, + }, select: { teamId: true }, orderBy: { teamId: "asc" }, }); @@ -370,4 +513,46 @@ export class FeaturesRepository implements IFeaturesRepository { throw err; } } + + /** + * Gets all user features for a specific user. + * @param userId - The ID of the user + * @returns Promise + */ + async getUserFeatures(userId: number) { + return this.prismaClient.userFeatures.findMany({ + where: { userId }, + include: { + feature: { + select: { + slug: true, + enabled: true, + description: true, + type: true, + }, + }, + }, + }); + } + + /** + * Gets all team features for a specific team. + * @param teamId - The ID of the team + * @returns Promise + */ + async getTeamFeaturesWithDetails(teamId: number) { + return this.prismaClient.teamFeatures.findMany({ + where: { teamId }, + include: { + feature: { + select: { + slug: true, + enabled: true, + description: true, + type: true, + }, + }, + }, + }); + } } diff --git a/packages/features/pbac/domain/types/permission-registry.ts b/packages/features/pbac/domain/types/permission-registry.ts index 0dd2a7b93df27d..9970d5bdc4e5e5 100644 --- a/packages/features/pbac/domain/types/permission-registry.ts +++ b/packages/features/pbac/domain/types/permission-registry.ts @@ -13,6 +13,7 @@ export enum Resource { Availability = "availability", OutOfOffice = "ooo", Watchlist = "watchlist", + Feature = "feature", } export enum CrudAction { @@ -750,4 +751,38 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { dependsOn: ["watchlist.read"], }, }, + [Resource.Feature]: { + _resource: { + i18nKey: "pbac_resource_feature", + }, + [CrudAction.Create]: { + description: "Create feature flags", + category: "feature", + i18nKey: "pbac_action_create", + descriptionI18nKey: "pbac_desc_create_features", + scope: [Scope.Organization], + dependsOn: ["feature.read"], + }, + [CrudAction.Read]: { + description: "View feature flags", + category: "feature", + i18nKey: "pbac_action_read", + descriptionI18nKey: "pbac_desc_view_features", + }, + [CrudAction.Update]: { + description: "Update feature flags", + category: "feature", + i18nKey: "pbac_action_update", + descriptionI18nKey: "pbac_desc_update_features", + dependsOn: ["feature.read"], + }, + [CrudAction.Delete]: { + description: "Delete feature flags", + category: "feature", + i18nKey: "pbac_action_delete", + descriptionI18nKey: "pbac_desc_delete_features", + scope: [Scope.Organization], + dependsOn: ["feature.read"], + }, + }, }; diff --git a/packages/trpc/server/routers/viewer/_router.tsx b/packages/trpc/server/routers/viewer/_router.tsx index 1ce6646198c66c..733d06e521ec29 100644 --- a/packages/trpc/server/routers/viewer/_router.tsx +++ b/packages/trpc/server/routers/viewer/_router.tsx @@ -1,4 +1,5 @@ import { userAdminRouter } from "@calcom/features/ee/users/server/trpc-router"; +import { featureManagementRouter } from "@calcom/features/feature-management/trpc/router"; import { featureFlagRouter } from "@calcom/features/flags/server/router"; import { insightsRouter } from "@calcom/features/insights/server/trpc-router"; @@ -75,6 +76,7 @@ export const viewerRouter = router({ // After that there would just one merge call here for all the apps. appRoutingForms: app_RoutingForms, features: featureFlagRouter, + featureManagement: featureManagementRouter, users: userAdminRouter, oAuth: oAuthRouter, googleWorkspace: googleWorkspaceRouter,