From adc198b37b1d7256a73f84ddd7f079fb4bff5416 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Mon, 19 Jan 2026 19:48:26 +0100 Subject: [PATCH] feat: add feature opt-in banner system (#26625) * docs: add feature opt-in banner system plan Co-Authored-By: eunjae@cal.com * docs: iterate on feature opt-in banner plan based on feedback - Use config.ts for feature metadata instead of TRPC response - Consolidate localStorage dismissal into single key to avoid bloat - Replace separate success dialog with in-dialog success state for smoother UX Co-Authored-By: eunjae@cal.com * feat: implement feature opt-in banner system - Add checkFeatureOptInEligibility TRPC procedure to check if user can opt-in - Create useFeatureOptInBanner hook with localStorage dismissal state - Create FeatureOptInBanner floating component for bottom-right display - Create FeatureOptInConfirmDialog with role-based options and success state - Integrate banner into bookings-view.tsx as example usage Co-Authored-By: eunjae@cal.com * feat: add i18n translation keys for feature opt-in banner * refactor: create FeatureOptInBannerWrapper for simpler consumer integration Co-Authored-By: eunjae@cal.com * refactor: use @calcom/lib/webstorage and add Zod schema for localStorage Co-Authored-By: eunjae@cal.com * refactor: address PR review comments - use PBAC, move logic to service, improve typing Co-Authored-By: eunjae@cal.com # Conflicts: # packages/features/feature-opt-in/services/FeatureOptInService.ts * test: add comprehensive tests for checkFeatureOptInEligibility method Co-Authored-By: eunjae@cal.com * fix: correct import and Checkbox type errors Co-Authored-By: eunjae@cal.com * update message * invalidate after dialog dismiss * feat: simulate user opt-in to determine banner eligibility When a feature has a strict policy and org/team hasn't explicitly enabled it, user opt-in alone won't enable the feature. This change simulates what would happen if the user opts in and only shows the banner if opting in would actually enable the feature. Co-Authored-By: eunjae@cal.com * fix: add early return for missing featureConfig and clarify simulation comment Co-Authored-By: eunjae@cal.com * fix repository usage * refactor: simplify FeatureOptInConfirmDialog UI and remove unused translation Co-Authored-By: eunjae@cal.com * refactor: replace radio buttons with multi-select dropdown in FeatureOptInConfirmDialog Co-Authored-By: eunjae@cal.com * resize banner * refactor: use AnimatedPopover pattern for team selection and rename titleI18nKey to nameI18nKey Co-Authored-By: eunjae@cal.com * fix: change 'Just for me' to 'For me', fix width clipping, remove divider Co-Authored-By: eunjae@cal.com * update style * feat: make 'Just for me' mutually exclusive with teams/org and store opt-ins in localStorage Co-Authored-By: eunjae@cal.com * refactor: remove barrel imports and update to direct imports Co-Authored-By: eunjae@cal.com * fix: add modal prop to AnimatedPopover to fix scroll lock conflict in dialogs Co-Authored-By: eunjae@cal.com * fix: wrap Popover.Content with Portal to prevent layout interference in dialogs Co-Authored-By: eunjae@cal.com * fix: use flexbox gap instead of space-y to prevent popover wrapper from affecting layout Co-Authored-By: eunjae@cal.com * Revert "fix: use flexbox gap instead of space-y to prevent popover wrapper from affecting layout" This reverts commit 8385adafa890c2f2674aa3ae3b10ca6e0c55ba5c. * Revert "fix: wrap Popover.Content with Portal to prevent layout interference in dialogs" This reverts commit e84301a35a99b3559d47c2d37316af290834ca2d. * Revert "fix: add modal prop to AnimatedPopover to fix scroll lock conflict in dialogs" This reverts commit b015af2f1cf31ef7087c02cb8535d96cc4a34125. * feat: replace Radix Dialog and AnimatedPopover with coss-ui components This migration uses @coss/ui Dialog and Popover components which are built on Base UI instead of Radix. Base UI handles scroll locking and positioning differently, which should fix the gap issue when opening the popover inside the dialog. Co-Authored-By: eunjae@cal.com * refactor: replace remaining @calcom/ui components with coss-ui - Replace Divider with Separator from @coss/ui - Replace Label with Label from @coss/ui - Replace CheckboxField with Checkbox + Label from @coss/ui - Replace Icon with direct lucide-react icons (UserIcon, BuildingIcon, UsersIcon, CheckIcon) - Replace showToast with toastManager.add() from @coss/ui Co-Authored-By: eunjae@cal.com * refactor: replace Popover with Menu component from coss-ui - Replace Popover + FilterCheckboxField with Menu + MenuCheckboxItem - MenuCheckboxItem provides built-in checkbox indicator and styling - MenuSeparator provides proper menu separators - Cleaner API with onCheckedChange callback - Better keyboard navigation support Co-Authored-By: eunjae@cal.com * fix: align icon and text on same line in MenuCheckboxItem, widen dialog - Wrap icon and text in flex container with items-center gap-2 - Change dialog width from sm:max-w-md to sm:max-w-lg to prevent text wrapping Co-Authored-By: eunjae@cal.com * use dimmed bg for coss dialog title * refactor: change nameI18nKey/descriptionI18nKey to nested i18n object - Change OptInFeatureConfig interface to use i18n: { name, description } - Update all component usages to use featureConfig.i18n.name and featureConfig.i18n.description - Update test mocks to use new structure Co-Authored-By: eunjae@cal.com * feat: add i18n.title to OptInFeatureConfig type definition Co-Authored-By: eunjae@cal.com * update texts * use title instead of name * update banner style * refactor: split FeatureOptInConfirmDialog into separate success and confirmation dialogs Co-Authored-By: eunjae@cal.com * fix: don't record dismissed info in local storage after successful opt-in Co-Authored-By: eunjae@cal.com * fix: add checkFeatureOptInEligibility to IFeatureOptInService interface Co-Authored-By: eunjae@cal.com * update common.json * refactor: move tRPC usage from features package to apps/web/modules Addresses Cubic AI review feedback (confidence 9/10): - Remove @calcom/trpc import from FeatureOptInConfirmDialog.tsx - Add FeatureOptInMutations type for passing mutation functions via props - Move tRPC hooks to useFeatureOptInBanner hook in apps/web/modules - Pass mutations through FeatureOptInBannerWrapper to dialog component This maintains the architectural constraint that packages/features should not import from @calcom/trpc to prevent circular dependencies. Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../modules/bookings/views/bookings-view.tsx | 13 +- .../ee/organizations/features-view.tsx | 5 +- .../web/modules/feature-opt-in/hooks/index.ts | 4 - .../hooks/useFeatureOptInBanner.ts | 164 ++++++++ .../hooks/useOrganizationFeatureOptIn.ts | 14 +- .../hooks/useTeamFeatureOptIn.ts | 32 +- .../hooks/useUserFeatureOptIn.ts | 24 +- .../settings/my-account/features-view.tsx | 5 +- .../settings/teams/[id]/features-view.tsx | 5 +- apps/web/public/opt_in_banner_bookings_v3.png | Bin 0 -> 27184 bytes apps/web/public/static/locales/en/common.json | 23 +- packages/coss-ui/src/styles/globals.css | 4 + .../FEATURE_OPT_IN_BANNER_PLAN.md | 201 ++++++++++ .../components/FeatureOptInBanner.tsx | 58 +++ .../components/FeatureOptInBannerWrapper.tsx | 65 ++++ .../components/FeatureOptInConfirmDialog.tsx | 302 +++++++++++++++ .../components/FeatureOptInSuccessDialog.tsx | 49 +++ .../components/FeaturesSettings.tsx | 4 +- packages/features/feature-opt-in/config.ts | 36 +- .../feature-opt-in/lib/applyAutoOptIn.test.ts | 2 +- .../lib/computeEffectiveState.test.ts | 37 +- .../FeatureOptInService.integration-test.ts | 3 +- .../services/FeatureOptInService.test.ts | 355 ++++++++++++++++-- .../services/FeatureOptInService.ts | 198 +++++++++- .../services/IFeatureOptInService.ts | 51 ++- packages/features/package.json | 1 + .../routers/viewer/featureOptIn/_router.ts | 39 +- yarn.lock | 1 + 28 files changed, 1591 insertions(+), 104 deletions(-) delete mode 100644 apps/web/modules/feature-opt-in/hooks/index.ts create mode 100644 apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.ts create mode 100644 apps/web/public/opt_in_banner_bookings_v3.png create mode 100644 packages/features/feature-opt-in/FEATURE_OPT_IN_BANNER_PLAN.md create mode 100644 packages/features/feature-opt-in/components/FeatureOptInBanner.tsx create mode 100644 packages/features/feature-opt-in/components/FeatureOptInBannerWrapper.tsx create mode 100644 packages/features/feature-opt-in/components/FeatureOptInConfirmDialog.tsx create mode 100644 packages/features/feature-opt-in/components/FeatureOptInSuccessDialog.tsx diff --git a/apps/web/modules/bookings/views/bookings-view.tsx b/apps/web/modules/bookings/views/bookings-view.tsx index f140409997..2720c2c32b 100644 --- a/apps/web/modules/bookings/views/bookings-view.tsx +++ b/apps/web/modules/bookings/views/bookings-view.tsx @@ -1,14 +1,15 @@ "use client"; +import { ColumnFilterType, DataTableProvider, type SystemFilterSegment } from "@calcom/features/data-table"; +import { useSegments } from "@calcom/features/data-table/hooks/useSegments"; +import FeatureOptInBannerWrapper from "@calcom/features/feature-opt-in/components/FeatureOptInBannerWrapper"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import classNames from "@calcom/ui/classNames"; import dynamic from "next/dynamic"; import { usePathname } from "next/navigation"; import { useMemo } from "react"; -import { DataTableProvider, type SystemFilterSegment, ColumnFilterType } from "@calcom/features/data-table"; -import { useSegments } from "@calcom/features/data-table/hooks/useSegments"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import classNames from "@calcom/ui/classNames"; - +import { useFeatureOptInBanner } from "../../feature-opt-in/hooks/useFeatureOptInBanner"; import { BookingListContainer } from "../components/BookingListContainer"; import { useBookingsShellHeadingVisibility } from "../hooks/useBookingsShellHeadingVisibility"; import { useBookingsView } from "../hooks/useBookingsView"; @@ -71,6 +72,7 @@ export default function Bookings(props: BookingsProps) { function BookingsContent({ status, permissions, bookingsV3Enabled, bookingAuditEnabled }: BookingsProps) { const [view] = useBookingsView({ bookingsV3Enabled }); + const optInBanner = useFeatureOptInBanner("bookings-v3"); useBookingsShellHeadingVisibility({ visible: view === "list" }); @@ -91,6 +93,7 @@ function BookingsContent({ status, permissions, bookingsV3Enabled, bookingAuditE bookingsV3Enabled={bookingsV3Enabled} /> )} + ); } diff --git a/apps/web/modules/ee/organizations/features-view.tsx b/apps/web/modules/ee/organizations/features-view.tsx index 0f7ae69f65..aa12f18722 100644 --- a/apps/web/modules/ee/organizations/features-view.tsx +++ b/apps/web/modules/ee/organizations/features-view.tsx @@ -1,12 +1,11 @@ "use client"; -import type { ReactElement } from "react"; - import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { ReactElement } from "react"; -import { useOrganizationFeatureOptIn } from "~/feature-opt-in/hooks"; +import { useOrganizationFeatureOptIn } from "~/feature-opt-in/hooks/useOrganizationFeatureOptIn"; interface OrganizationFeaturesViewProps { canEdit: boolean; diff --git a/apps/web/modules/feature-opt-in/hooks/index.ts b/apps/web/modules/feature-opt-in/hooks/index.ts deleted file mode 100644 index 299c32db4c..0000000000 --- a/apps/web/modules/feature-opt-in/hooks/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { useUserFeatureOptIn } from "./useUserFeatureOptIn"; -export { useTeamFeatureOptIn } from "./useTeamFeatureOptIn"; -export { useOrganizationFeatureOptIn } from "./useOrganizationFeatureOptIn"; -export type { UseFeatureOptInResult, NormalizedFeature, ToggleLabels } from "@calcom/features/feature-opt-in/types"; diff --git a/apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.ts b/apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.ts new file mode 100644 index 0000000000..7f9a8a4e83 --- /dev/null +++ b/apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.ts @@ -0,0 +1,164 @@ +"use client"; + +import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config"; +import { getOptInFeatureConfig } from "@calcom/features/feature-opt-in/config"; +import { localStorage } from "@calcom/lib/webstorage"; +import { trpc } from "@calcom/trpc/react"; +import { useCallback, useMemo, useState } from "react"; +import { z } from "zod"; + +const DISMISSED_STORAGE_KEY = "feature-opt-in-dismissed"; +const OPTED_IN_STORAGE_KEY = "feature-opt-in-enabled"; + +const featuresMapSchema = z.record(z.string(), z.boolean()); + +type FeaturesMap = z.infer; + +type UserRoleContext = { + isOrgAdmin: boolean; + orgId: number | null; + adminTeamIds: number[]; + adminTeamNames: { id: number; name: string }[]; +}; + +export type FeatureOptInMutations = { + setUserState: (params: { slug: string; state: "enabled" }) => Promise; + setTeamState: (params: { teamId: number; slug: string; state: "enabled" }) => Promise; + setOrganizationState: (params: { slug: string; state: "enabled" }) => Promise; + setUserAutoOptIn: (params: { autoOptIn: boolean }) => Promise; + setTeamAutoOptIn: (params: { teamId: number; autoOptIn: boolean }) => Promise; + setOrganizationAutoOptIn: (params: { autoOptIn: boolean }) => Promise; + invalidateQueries: () => void; +}; + +export type UseFeatureOptInBannerResult = { + shouldShow: boolean; + isLoading: boolean; + featureConfig: OptInFeatureConfig | null; + canOptIn: boolean; + blockingReason: string | null; + userRoleContext: UserRoleContext | null; + isDialogOpen: boolean; + openDialog: () => void; + closeDialog: () => void; + dismiss: () => void; + markOptedIn: () => void; + mutations: FeatureOptInMutations; +}; + +function getFeaturesMap(storageKey: string): FeaturesMap { + const stored = localStorage.getItem(storageKey); + if (!stored) return {}; + try { + const parsed = JSON.parse(stored); + const result = featuresMapSchema.safeParse(parsed); + if (result.success) { + return result.data; + } + return {}; + } catch { + return {}; + } +} + +function setFeatureInMap(storageKey: string, featureId: string): void { + const current = getFeaturesMap(storageKey); + current[featureId] = true; + localStorage.setItem(storageKey, JSON.stringify(current)); +} + +function isFeatureInMap(storageKey: string, featureId: string): boolean { + const features = getFeaturesMap(storageKey); + return features[featureId] === true; +} + +export function useFeatureOptInBanner(featureId: string): UseFeatureOptInBannerResult { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isDismissed, setIsDismissed] = useState(() => isFeatureInMap(DISMISSED_STORAGE_KEY, featureId)); + const [isOptedIn, setIsOptedIn] = useState(() => isFeatureInMap(OPTED_IN_STORAGE_KEY, featureId)); + + const featureConfig = useMemo(() => getOptInFeatureConfig(featureId) ?? null, [featureId]); + const utils = trpc.useUtils(); + + const eligibilityQuery = trpc.viewer.featureOptIn.checkFeatureOptInEligibility.useQuery( + { featureId }, + { + enabled: !isDismissed && !isOptedIn && featureConfig !== null, + refetchOnWindowFocus: false, + staleTime: 1000 * 60 * 5, + } + ); + + const setUserStateMutation = trpc.viewer.featureOptIn.setUserState.useMutation(); + const setTeamStateMutation = trpc.viewer.featureOptIn.setTeamState.useMutation(); + const setOrganizationStateMutation = trpc.viewer.featureOptIn.setOrganizationState.useMutation(); + const setUserAutoOptInMutation = trpc.viewer.featureOptIn.setUserAutoOptIn.useMutation(); + const setTeamAutoOptInMutation = trpc.viewer.featureOptIn.setTeamAutoOptIn.useMutation(); + const setOrganizationAutoOptInMutation = trpc.viewer.featureOptIn.setOrganizationAutoOptIn.useMutation(); + + const mutations: FeatureOptInMutations = useMemo( + () => ({ + setUserState: (params) => setUserStateMutation.mutateAsync(params), + setTeamState: (params) => setTeamStateMutation.mutateAsync(params), + setOrganizationState: (params) => setOrganizationStateMutation.mutateAsync(params), + setUserAutoOptIn: (params) => setUserAutoOptInMutation.mutateAsync(params), + setTeamAutoOptIn: (params) => setTeamAutoOptInMutation.mutateAsync(params), + setOrganizationAutoOptIn: (params) => setOrganizationAutoOptInMutation.mutateAsync(params), + invalidateQueries: () => { + utils.viewer.featureOptIn.checkFeatureOptInEligibility.invalidate(); + utils.viewer.featureOptIn.listForUser.invalidate(); + }, + }), + [ + setUserStateMutation, + setTeamStateMutation, + setOrganizationStateMutation, + setUserAutoOptInMutation, + setTeamAutoOptInMutation, + setOrganizationAutoOptInMutation, + utils, + ] + ); + + const dismiss = useCallback(() => { + setFeatureInMap(DISMISSED_STORAGE_KEY, featureId); + setIsDismissed(true); + }, [featureId]); + + const markOptedIn = useCallback(() => { + setFeatureInMap(OPTED_IN_STORAGE_KEY, featureId); + setIsOptedIn(true); + }, [featureId]); + + const openDialog = useCallback(() => { + setIsDialogOpen(true); + }, []); + + const closeDialog = useCallback(() => { + setIsDialogOpen(false); + }, []); + + const shouldShow = useMemo(() => { + if (isDismissed) return false; + if (isOptedIn) return false; + if (!featureConfig) return false; + if (eligibilityQuery.isLoading) return false; + if (!eligibilityQuery.data) return false; + return eligibilityQuery.data.status === "can_opt_in"; + }, [isDismissed, isOptedIn, featureConfig, eligibilityQuery.isLoading, eligibilityQuery.data]); + + return { + shouldShow, + isLoading: eligibilityQuery.isLoading, + featureConfig, + canOptIn: eligibilityQuery.data?.canOptIn ?? false, + blockingReason: eligibilityQuery.data?.blockingReason ?? null, + userRoleContext: eligibilityQuery.data?.userRoleContext ?? null, + isDialogOpen, + openDialog, + closeDialog, + dismiss, + markOptedIn, + mutations, + }; +} diff --git a/apps/web/modules/feature-opt-in/hooks/useOrganizationFeatureOptIn.ts b/apps/web/modules/feature-opt-in/hooks/useOrganizationFeatureOptIn.ts index 21d834cf8a..fd7a916c11 100644 --- a/apps/web/modules/feature-opt-in/hooks/useOrganizationFeatureOptIn.ts +++ b/apps/web/modules/feature-opt-in/hooks/useOrganizationFeatureOptIn.ts @@ -1,12 +1,11 @@ "use client"; -import { useCallback, useMemo } from "react"; - import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types"; import type { FeatureState } from "@calcom/features/flags/config"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { showToast } from "@calcom/ui/components/toast"; +import { useCallback, useMemo } from "react"; function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } { const { t } = useLocale(); @@ -56,7 +55,10 @@ export function useOrganizationFeatureOptIn(): UseFeatureOptInResult { refetchOnWindowFocus: false, }); - const invalidateFeatures = useCallback(() => utils.viewer.featureOptIn.listForOrganization.invalidate(), [utils]); + const invalidateFeatures = useCallback( + () => utils.viewer.featureOptIn.listForOrganization.invalidate(), + [utils] + ); const invalidateFeaturesAndAutoOptIn = useCallback(() => { utils.viewer.featureOptIn.getOrganizationAutoOptIn.invalidate(); utils.viewer.featureOptIn.listForOrganization.invalidate(); @@ -65,13 +67,15 @@ export function useOrganizationFeatureOptIn(): UseFeatureOptInResult { const setStateMutationCallbacks = useMutationCallbacks(invalidateFeatures); const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn); - const setStateMutation = trpc.viewer.featureOptIn.setOrganizationState.useMutation(setStateMutationCallbacks); + const setStateMutation = + trpc.viewer.featureOptIn.setOrganizationState.useMutation(setStateMutationCallbacks); const setAutoOptInMutation = trpc.viewer.featureOptIn.setOrganizationAutoOptIn.useMutation( setAutoOptInMutationCallbacks ); const features = normalizeFeatures(featuresQuery.data); - const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ slug, state }); + const setFeatureState = (slug: string, state: FeatureState): void => + setStateMutation.mutate({ slug, state }); const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ autoOptIn: checked }); return { diff --git a/apps/web/modules/feature-opt-in/hooks/useTeamFeatureOptIn.ts b/apps/web/modules/feature-opt-in/hooks/useTeamFeatureOptIn.ts index afe980853b..cc4650ba67 100644 --- a/apps/web/modules/feature-opt-in/hooks/useTeamFeatureOptIn.ts +++ b/apps/web/modules/feature-opt-in/hooks/useTeamFeatureOptIn.ts @@ -1,16 +1,20 @@ "use client"; -import type { TFunction } from "i18next"; -import { useCallback, useMemo } from "react"; - import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types"; import type { FeatureState } from "@calcom/features/flags/config"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { showToast } from "@calcom/ui/components/toast"; +import type { TFunction } from "i18next"; +import { useCallback, useMemo } from "react"; type FeatureBlockingState = { orgState?: FeatureState }; -type TeamFeatureData = { featureId: string; globalEnabled: boolean; teamState: FeatureState; orgState: FeatureState }; +type TeamFeatureData = { + featureId: string; + globalEnabled: boolean; + teamState: FeatureState; + orgState: FeatureState; +}; function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } { const { t } = useLocale(); @@ -73,8 +77,14 @@ export function useTeamFeatureOptIn(teamId: number): UseFeatureOptInResult { const { t } = useLocale(); const utils = trpc.useUtils(); - const featuresQuery = trpc.viewer.featureOptIn.listForTeam.useQuery({ teamId }, { refetchOnWindowFocus: false }); - const autoOptInQuery = trpc.viewer.featureOptIn.getTeamAutoOptIn.useQuery({ teamId }, { refetchOnWindowFocus: false }); + const featuresQuery = trpc.viewer.featureOptIn.listForTeam.useQuery( + { teamId }, + { refetchOnWindowFocus: false } + ); + const autoOptInQuery = trpc.viewer.featureOptIn.getTeamAutoOptIn.useQuery( + { teamId }, + { refetchOnWindowFocus: false } + ); const invalidateFeatures = useCallback( () => utils.viewer.featureOptIn.listForTeam.invalidate({ teamId }), @@ -89,12 +99,16 @@ export function useTeamFeatureOptIn(teamId: number): UseFeatureOptInResult { const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn); const setStateMutation = trpc.viewer.featureOptIn.setTeamState.useMutation(setStateMutationCallbacks); - const setAutoOptInMutation = trpc.viewer.featureOptIn.setTeamAutoOptIn.useMutation(setAutoOptInMutationCallbacks); + const setAutoOptInMutation = trpc.viewer.featureOptIn.setTeamAutoOptIn.useMutation( + setAutoOptInMutationCallbacks + ); const featureBlockingState = useMemo(() => buildBlockingStateMap(featuresQuery.data), [featuresQuery.data]); const features = normalizeTeamFeatures(featuresQuery.data); - const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ teamId, slug, state }); - const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ teamId, autoOptIn: checked }); + const setFeatureState = (slug: string, state: FeatureState): void => + setStateMutation.mutate({ teamId, slug, state }); + const setAutoOptIn = (checked: boolean): void => + setAutoOptInMutation.mutate({ teamId, autoOptIn: checked }); const getBlockedWarning = createTeamBlockedWarningFn(featureBlockingState, t); const isBlockedByHigherLevel = createTeamIsBlockedByHigherLevelFn(featureBlockingState); diff --git a/apps/web/modules/feature-opt-in/hooks/useUserFeatureOptIn.ts b/apps/web/modules/feature-opt-in/hooks/useUserFeatureOptIn.ts index 70ccc5bde7..7f899d3b84 100644 --- a/apps/web/modules/feature-opt-in/hooks/useUserFeatureOptIn.ts +++ b/apps/web/modules/feature-opt-in/hooks/useUserFeatureOptIn.ts @@ -1,15 +1,13 @@ "use client"; -import type { TFunction } from "i18next"; - -import { useCallback, useMemo } from "react"; - import type { EffectiveStateReason } from "@calcom/features/feature-opt-in/lib/computeEffectiveState"; import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types"; import type { FeatureState } from "@calcom/features/flags/config"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { showToast } from "@calcom/ui/components/toast"; +import type { TFunction } from "i18next"; +import { useCallback, useMemo } from "react"; type UserFeatureData = { featureId: string; @@ -53,8 +51,9 @@ function createUserBlockedWarningFn(t: TFunction): (feature: NormalizedFeature) case "feature_any_team_disabled": return t("feature_blocked_by_team_warning"); case "feature_no_explicit_enablement": - case "feature_user_only_not_allowed": return t("feature_no_explicit_enablement_warning"); + case "feature_user_only_not_allowed": + return t("feature_user_only_not_allowed_warning"); default: return null; } @@ -76,8 +75,12 @@ export function useUserFeatureOptIn(): UseFeatureOptInResult { const { t } = useLocale(); const utils = trpc.useUtils(); - const featuresQuery = trpc.viewer.featureOptIn.listForUser.useQuery(undefined, { refetchOnWindowFocus: false }); - const autoOptInQuery = trpc.viewer.featureOptIn.getUserAutoOptIn.useQuery(undefined, { refetchOnWindowFocus: false }); + const featuresQuery = trpc.viewer.featureOptIn.listForUser.useQuery(undefined, { + refetchOnWindowFocus: false, + }); + const autoOptInQuery = trpc.viewer.featureOptIn.getUserAutoOptIn.useQuery(undefined, { + refetchOnWindowFocus: false, + }); const invalidateFeatures = useCallback(() => utils.viewer.featureOptIn.listForUser.invalidate(), [utils]); const invalidateFeaturesAndAutoOptIn = useCallback(() => { @@ -89,10 +92,13 @@ export function useUserFeatureOptIn(): UseFeatureOptInResult { const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn); const setStateMutation = trpc.viewer.featureOptIn.setUserState.useMutation(setStateMutationCallbacks); - const setAutoOptInMutation = trpc.viewer.featureOptIn.setUserAutoOptIn.useMutation(setAutoOptInMutationCallbacks); + const setAutoOptInMutation = trpc.viewer.featureOptIn.setUserAutoOptIn.useMutation( + setAutoOptInMutationCallbacks + ); const features = useMemo(() => normalizeUserFeatures(featuresQuery.data), [featuresQuery.data]); - const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ slug, state }); + const setFeatureState = (slug: string, state: FeatureState): void => + setStateMutation.mutate({ slug, state }); const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ autoOptIn: checked }); const getBlockedWarning = createUserBlockedWarningFn(t); diff --git a/apps/web/modules/settings/my-account/features-view.tsx b/apps/web/modules/settings/my-account/features-view.tsx index 9c72e8722a..117a86f32a 100644 --- a/apps/web/modules/settings/my-account/features-view.tsx +++ b/apps/web/modules/settings/my-account/features-view.tsx @@ -1,12 +1,11 @@ "use client"; -import type { ReactElement } from "react"; - import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { ReactElement } from "react"; -import { useUserFeatureOptIn } from "~/feature-opt-in/hooks"; +import { useUserFeatureOptIn } from "~/feature-opt-in/hooks/useUserFeatureOptIn"; const FeaturesView = (): ReactElement => { const { t } = useLocale(); diff --git a/apps/web/modules/settings/teams/[id]/features-view.tsx b/apps/web/modules/settings/teams/[id]/features-view.tsx index 51e1ae0da4..c8fb5df99e 100644 --- a/apps/web/modules/settings/teams/[id]/features-view.tsx +++ b/apps/web/modules/settings/teams/[id]/features-view.tsx @@ -1,12 +1,11 @@ "use client"; -import type { ReactElement } from "react"; - import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { ReactElement } from "react"; -import { useTeamFeatureOptIn } from "~/feature-opt-in/hooks"; +import { useTeamFeatureOptIn } from "~/feature-opt-in/hooks/useTeamFeatureOptIn"; interface TeamFeaturesViewProps { teamId: number; diff --git a/apps/web/public/opt_in_banner_bookings_v3.png b/apps/web/public/opt_in_banner_bookings_v3.png new file mode 100644 index 0000000000000000000000000000000000000000..6607402b27957f1aeaf904143a81bb41a80f762a GIT binary patch literal 27184 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 z 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 k(Jgtp<{}Bw)}^}%@a*R^^L?Z5fdzb&}` + +This pattern can be replicated for any view where a feature opt-in banner is needed. + +--- + +## 5. Policy Considerations + +The existing `computeEffectiveStateAcrossTeams` function already handles the two policies: + +**Permissive Policy:** +- User can opt-in even if org/teams haven't explicitly enabled +- Only blocked if ALL teams have explicitly disabled + +**Strict Policy:** +- User cannot opt-in alone; requires org/team to enable first +- ANY explicit disable from org/team blocks the user + +The TRPC procedure should use the feature's policy (from `OPT_IN_FEATURES` config) to determine if the user can opt-in. + +--- + +## 6. Data Flow Summary + +``` +View (bookings-view.tsx) + | + v +useFeatureOptInBanner("bookings-v3") + | + v +[Check localStorage for dismissal] + | (if not dismissed) + v +TRPC: checkFeatureOptInEligibility + | + v +FeatureOptInService.resolveFeatureStatesAcrossTeams + | + v +[Return eligibility + user role context] + | + v +Render FeatureOptInBanner (if eligible) + | (on click) + v +FeatureOptInConfirmDialog + | (on confirm) + v +Existing mutations: setUserState / setTeamState / setOrganizationState + | + v +[Dialog transitions to success state] +``` + +--- + +## 7. Key Dependencies on PR #25892 + +This plan relies on these existing pieces from the underlying PR: +- `FeatureOptInService` and `resolveFeatureStatesAcrossTeams` +- `computeEffectiveStateAcrossTeams` with policy support +- `OPT_IN_FEATURES` config with policy field +- Existing mutations for setting user/team/org states +- Settings pages at `/settings/*/features` + +--- + +## 8. Files to Create/Modify + +### New Files: +- `packages/features/feature-opt-in/components/FeatureOptInBanner.tsx` +- `packages/features/feature-opt-in/components/FeatureOptInConfirmDialog.tsx` (includes success state handling) +- `apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.ts` + +### Modified Files: +- `packages/trpc/server/routers/viewer/featureOptIn/_router.ts` (add new procedure) +- `apps/web/modules/bookings/views/bookings-view.tsx` (integrate banner) +- Translation files for new i18n keys diff --git a/packages/features/feature-opt-in/components/FeatureOptInBanner.tsx b/packages/features/feature-opt-in/components/FeatureOptInBanner.tsx new file mode 100644 index 0000000000..b24e371d3c --- /dev/null +++ b/packages/features/feature-opt-in/components/FeatureOptInBanner.tsx @@ -0,0 +1,58 @@ +"use client"; + +import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@calcom/ui/components/button"; +import Image from "next/image"; +import type { ReactElement } from "react"; + +interface FeatureOptInBannerProps { + featureConfig: OptInFeatureConfig; + onDismiss: () => void; + onOpenDialog: () => void; +} + +export function FeatureOptInBanner({ + featureConfig, + onDismiss, + onOpenDialog, +}: FeatureOptInBannerProps): ReactElement { + const { t } = useLocale(); + + return ( +
+
+

{t(featureConfig.i18n.title)}

+

{t(featureConfig.i18n.description)}

+ + + + +
+ +
+
+ {t(featureConfig.i18n.title)} +
+
+
+ ); +} + +export default FeatureOptInBanner; diff --git a/packages/features/feature-opt-in/components/FeatureOptInBannerWrapper.tsx b/packages/features/feature-opt-in/components/FeatureOptInBannerWrapper.tsx new file mode 100644 index 0000000000..eac60dc766 --- /dev/null +++ b/packages/features/feature-opt-in/components/FeatureOptInBannerWrapper.tsx @@ -0,0 +1,65 @@ +"use client"; + +import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config"; +import type { ReactElement } from "react"; + +import { FeatureOptInBanner } from "./FeatureOptInBanner"; +import type { FeatureOptInMutations } from "./FeatureOptInConfirmDialog"; +import { FeatureOptInConfirmDialog } from "./FeatureOptInConfirmDialog"; + +type UserRoleContext = { + isOrgAdmin: boolean; + orgId: number | null; + adminTeamIds: number[]; + adminTeamNames: { id: number; name: string }[]; +}; + +type FeatureOptInBannerState = { + shouldShow: boolean; + isLoading: boolean; + featureConfig: OptInFeatureConfig | null; + canOptIn: boolean; + blockingReason: string | null; + userRoleContext: UserRoleContext | null; + isDialogOpen: boolean; + openDialog: () => void; + closeDialog: () => void; + dismiss: () => void; + markOptedIn: () => void; + mutations: FeatureOptInMutations; +}; + +interface FeatureOptInBannerWrapperProps { + state: FeatureOptInBannerState; +} + +function FeatureOptInBannerWrapper({ state }: FeatureOptInBannerWrapperProps): ReactElement | null { + if (!state.featureConfig) { + return null; + } + + return ( + <> + {state.shouldShow && ( + + )} + {state.userRoleContext && ( + + )} + + ); +} + +export default FeatureOptInBannerWrapper; diff --git a/packages/features/feature-opt-in/components/FeatureOptInConfirmDialog.tsx b/packages/features/feature-opt-in/components/FeatureOptInConfirmDialog.tsx new file mode 100644 index 0000000000..8b080426fc --- /dev/null +++ b/packages/features/feature-opt-in/components/FeatureOptInConfirmDialog.tsx @@ -0,0 +1,302 @@ +"use client"; + +import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@coss/ui/components/button"; +import { Checkbox } from "@coss/ui/components/checkbox"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@coss/ui/components/dialog"; +import { Label } from "@coss/ui/components/label"; +import { Menu, MenuCheckboxItem, MenuPopup, MenuSeparator, MenuTrigger } from "@coss/ui/components/menu"; +import { toastManager } from "@coss/ui/components/toast"; +import { BuildingIcon, ChevronDownIcon, UserIcon, UsersIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import type { ReactElement } from "react"; +import { useState } from "react"; + +import { FeatureOptInSuccessDialog } from "./FeatureOptInSuccessDialog"; + +type UserRoleContext = { + isOrgAdmin: boolean; + orgId: number | null; + adminTeamIds: number[]; + adminTeamNames: { id: number; name: string }[]; +}; + +export type FeatureOptInMutations = { + setUserState: (params: { slug: string; state: "enabled" }) => Promise; + setTeamState: (params: { teamId: number; slug: string; state: "enabled" }) => Promise; + setOrganizationState: (params: { slug: string; state: "enabled" }) => Promise; + setUserAutoOptIn: (params: { autoOptIn: boolean }) => Promise; + setTeamAutoOptIn: (params: { teamId: number; autoOptIn: boolean }) => Promise; + setOrganizationAutoOptIn: (params: { autoOptIn: boolean }) => Promise; + invalidateQueries: () => void; +}; + +interface FeatureOptInConfirmDialogProps { + isOpen: boolean; + onClose: () => void; + onDismissBanner: () => void; + onOptInSuccess?: () => void; + featureConfig: OptInFeatureConfig; + userRoleContext: UserRoleContext; + mutations: FeatureOptInMutations; +} + +export function FeatureOptInConfirmDialog({ + isOpen, + onClose, + onDismissBanner, + onOptInSuccess, + featureConfig, + userRoleContext, + mutations, +}: FeatureOptInConfirmDialogProps): ReactElement { + const { t } = useLocale(); + const router = useRouter(); + + const { isOrgAdmin, orgId, adminTeamIds, adminTeamNames } = userRoleContext; + const hasAdminTeams = adminTeamIds.length > 0; + const canEnableForOrg = isOrgAdmin && orgId !== null; + const canEnableForTeams = hasAdminTeams; + const showSelector = canEnableForOrg || canEnableForTeams; + + const [enableForUser, setEnableForUser] = useState(true); + const [enableForOrg, setEnableForOrg] = useState(false); + const [selectedTeamIds, setSelectedTeamIds] = useState([]); + const [autoOptIn, setAutoOptIn] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [shouldInvalidate, setShouldInvalidate] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const hasTeamsSelected = selectedTeamIds.length > 0; + const hasAnySelection = enableForUser || enableForOrg || hasTeamsSelected; + + const getSelectedText = (): string => { + const parts: string[] = []; + if (enableForUser) parts.push(t("just_for_me")); + if (enableForOrg) parts.push(t("entire_organization")); + if (hasTeamsSelected) { + const teamNames = adminTeamNames + .filter((team) => selectedTeamIds.includes(team.id)) + .map((team) => team.name); + parts.push(...teamNames); + } + return parts.length > 0 ? parts.join(", ") : t("select"); + }; + + const handleUserChange = (checked: boolean): void => { + setEnableForUser(checked); + if (checked) { + setEnableForOrg(false); + setSelectedTeamIds([]); + } + }; + + const handleOrgChange = (checked: boolean): void => { + setEnableForOrg(checked); + if (checked) { + setEnableForUser(false); + setSelectedTeamIds([]); + } + }; + + const handleTeamChange = (teamId: number, checked: boolean): void => { + if (checked) { + setSelectedTeamIds((prev) => [...prev, teamId]); + setEnableForUser(false); + setEnableForOrg(false); + } else { + setSelectedTeamIds((prev) => prev.filter((id) => id !== teamId)); + } + }; + + const getAutoOptInText = (): string => { + if (enableForOrg) { + return t("auto_opt_in_future_features_org"); + } + if (hasTeamsSelected) { + return t("auto_opt_in_future_features_teams"); + } + return t("auto_opt_in_future_features_user"); + }; + + const handleConfirm = async (): Promise => { + setIsSubmitting(true); + try { + const promises: Promise[] = []; + + if (enableForOrg && orgId) { + promises.push(mutations.setOrganizationState({ slug: featureConfig.slug, state: "enabled" })); + if (autoOptIn) { + promises.push(mutations.setOrganizationAutoOptIn({ autoOptIn: true })); + } + } + + if (hasTeamsSelected) { + selectedTeamIds.forEach((teamId) => { + promises.push(mutations.setTeamState({ teamId, slug: featureConfig.slug, state: "enabled" })); + if (autoOptIn) { + promises.push(mutations.setTeamAutoOptIn({ teamId, autoOptIn: true })); + } + }); + } + + if (enableForUser) { + promises.push(mutations.setUserState({ slug: featureConfig.slug, state: "enabled" })); + if (autoOptIn) { + promises.push(mutations.setUserAutoOptIn({ autoOptIn: true })); + } + } + + await Promise.all(promises); + + setIsSuccess(true); + setShouldInvalidate(true); + onOptInSuccess?.(); + } catch (_error) { + toastManager.add({ title: t("error_enabling_feature"), type: "error" }); + } finally { + setIsSubmitting(false); + } + }; + + const getSettingsRedirectPath = (): string => { + if (enableForOrg) { + return "/settings/organizations/features"; + } + if (hasTeamsSelected && selectedTeamIds.length > 0) { + return `/settings/teams/${selectedTeamIds[0]}/features`; + } + return "/settings/my-account/features"; + }; + + const handleViewSettings = (): void => { + resetAndClose(); + router.push(getSettingsRedirectPath()); + }; + + const handleDismiss = (): void => { + resetAndClose(); + }; + + const resetAndClose = (): void => { + if (shouldInvalidate) { + mutations.invalidateQueries(); + setShouldInvalidate(false); + } + setIsSuccess(false); + setEnableForUser(true); + setEnableForOrg(false); + setSelectedTeamIds([]); + setAutoOptIn(false); + onClose(); + }; + + if (isSuccess) { + return ( + + ); + } + + return ( + + + + {t(featureConfig.i18n.name)} + {t(featureConfig.i18n.description)} + + +
+ {showSelector && ( +
+ + + + }> + {getSelectedText()} + + + handleUserChange(checked)}> + + + {t("just_for_me")} + + + + {canEnableForOrg && ( + <> + + handleOrgChange(checked)}> + + + {t("entire_organization")} + + + + )} + + {canEnableForTeams && adminTeamNames.length > 0 && ( + <> + + {adminTeamNames.map((team) => ( + handleTeamChange(team.id, checked)}> + + + {team.name} + + + ))} + + )} + + +
+ )} + + +
+
+ + } onClick={resetAndClose} disabled={isSubmitting}> + {t("cancel")} + + + +
+
+ ); +} + +export default FeatureOptInConfirmDialog; diff --git a/packages/features/feature-opt-in/components/FeatureOptInSuccessDialog.tsx b/packages/features/feature-opt-in/components/FeatureOptInSuccessDialog.tsx new file mode 100644 index 0000000000..593ee7d074 --- /dev/null +++ b/packages/features/feature-opt-in/components/FeatureOptInSuccessDialog.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@coss/ui/components/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "@coss/ui/components/dialog"; +import type { ReactElement } from "react"; + +interface FeatureOptInSuccessDialogProps { + isOpen: boolean; + onClose: () => void; + onDismiss: () => void; + onViewSettings: () => void; +} + +export function FeatureOptInSuccessDialog({ + isOpen, + onClose, + onDismiss, + onViewSettings, +}: FeatureOptInSuccessDialogProps): ReactElement { + const { t } = useLocale(); + + return ( + + + + {t("feature_enabled_successfully")} + {t("feature_enabled_description")} + + + } onClick={onDismiss}> + {t("dismiss")} + + + + + + ); +} + +export default FeatureOptInSuccessDialog; diff --git a/packages/features/feature-opt-in/components/FeaturesSettings.tsx b/packages/features/feature-opt-in/components/FeaturesSettings.tsx index c73846a45a..3e5e4eb983 100644 --- a/packages/features/feature-opt-in/components/FeaturesSettings.tsx +++ b/packages/features/feature-opt-in/components/FeaturesSettings.tsx @@ -101,7 +101,7 @@ function FeatureItem({ "text-emphasis flex items-center text-sm font-medium", blockedWarning && "text-subtle line-through" )}> - {t(config.titleI18nKey)} + {t(config.i18n.name)} {blockedWarning && ( @@ -117,7 +117,7 @@ function FeatureItem({ )} -

{t(config.descriptionI18nKey)}

+

{t(config.i18n.description)}

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")); +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")); +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")); +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. diff --git a/packages/features/feature-opt-in/lib/applyAutoOptIn.test.ts b/packages/features/feature-opt-in/lib/applyAutoOptIn.test.ts index ceb86c30bf..b6ea26264e 100644 --- a/packages/features/feature-opt-in/lib/applyAutoOptIn.test.ts +++ b/packages/features/feature-opt-in/lib/applyAutoOptIn.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { applyAutoOptIn } from "./applyAutoOptIn"; diff --git a/packages/features/feature-opt-in/lib/computeEffectiveState.test.ts b/packages/features/feature-opt-in/lib/computeEffectiveState.test.ts index 758abcb702..153acf647f 100644 --- a/packages/features/feature-opt-in/lib/computeEffectiveState.test.ts +++ b/packages/features/feature-opt-in/lib/computeEffectiveState.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect } from "vitest"; import type { FeatureState } from "@calcom/features/flags/config"; -import { computeEffectiveStateAcrossTeams } from "./computeEffectiveState"; +import { describe, expect, it } from "vitest"; import type { OptInFeaturePolicy } from "../types"; +import { computeEffectiveStateAcrossTeams } from "./computeEffectiveState"; /** * Feature Opt-In Effective State Computation @@ -63,9 +63,24 @@ import type { OptInFeaturePolicy } from "../types"; describe("computeEffectiveStateAcrossTeams", () => { describe("Global Kill Switch", () => { it.each([ - { policy: "permissive" as OptInFeaturePolicy, orgState: "enabled" as FeatureState, teamStates: ["enabled"] as FeatureState[], userState: "enabled" as FeatureState }, - { policy: "strict" as OptInFeaturePolicy, orgState: "enabled" as FeatureState, teamStates: ["enabled"] as FeatureState[], userState: "enabled" as FeatureState }, - ])("blocks feature when global is disabled (policy: $policy)", ({ policy, orgState, teamStates, userState }) => { + { + policy: "permissive" as OptInFeaturePolicy, + orgState: "enabled" as FeatureState, + teamStates: ["enabled"] as FeatureState[], + userState: "enabled" as FeatureState, + }, + { + policy: "strict" as OptInFeaturePolicy, + orgState: "enabled" as FeatureState, + teamStates: ["enabled"] as FeatureState[], + userState: "enabled" as FeatureState, + }, + ])("blocks feature when global is disabled (policy: $policy)", ({ + policy, + orgState, + teamStates, + userState, + }) => { expect( computeEffectiveStateAcrossTeams({ globalEnabled: false, @@ -80,8 +95,16 @@ describe("computeEffectiveStateAcrossTeams", () => { describe("Organization Level Blocking", () => { it.each([ - { policy: "permissive" as OptInFeaturePolicy, teamStates: ["enabled"] as FeatureState[], userState: "enabled" as FeatureState }, - { policy: "strict" as OptInFeaturePolicy, teamStates: ["enabled"] as FeatureState[], userState: "enabled" as FeatureState }, + { + policy: "permissive" as OptInFeaturePolicy, + teamStates: ["enabled"] as FeatureState[], + userState: "enabled" as FeatureState, + }, + { + policy: "strict" as OptInFeaturePolicy, + teamStates: ["enabled"] as FeatureState[], + userState: "enabled" as FeatureState, + }, ])("blocks feature when org is disabled (policy: $policy)", ({ policy, teamStates, userState }) => { expect( computeEffectiveStateAcrossTeams({ 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 fed1c101ea..df39304156 100644 --- a/packages/features/feature-opt-in/services/FeatureOptInService.integration-test.ts +++ b/packages/features/feature-opt-in/services/FeatureOptInService.integration-test.ts @@ -1,10 +1,9 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - import { getFeatureOptInService } from "@calcom/features/di/containers/FeatureOptInService"; import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository"; import type { FeatureId } from "@calcom/features/flags/config"; import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { prisma } from "@calcom/prisma"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { IFeatureOptInService } from "./IFeatureOptInService"; diff --git a/packages/features/feature-opt-in/services/FeatureOptInService.test.ts b/packages/features/feature-opt-in/services/FeatureOptInService.test.ts index a5e0ee489f..95ebb33e3b 100644 --- a/packages/features/feature-opt-in/services/FeatureOptInService.test.ts +++ b/packages/features/feature-opt-in/services/FeatureOptInService.test.ts @@ -1,32 +1,96 @@ -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 { beforeEach, describe, expect, it, vi } from "vitest"; import { FeatureOptInService } from "./FeatureOptInService"; const mockIsFeatureAllowedForScope = vi.fn(); +const mockFindAllByUserId = vi.fn(); +// Mock the OPT_IN_FEATURES config 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"] }, - ]; + const mockFeatures = [ + { + slug: "test-feature-1", + i18n: { title: "test_feature_1_title", name: "test_feature_1", description: "test_feature_1_desc" }, + policy: "permissive", + }, + { + slug: "test-feature-2", + i18n: { title: "test_feature_2_title", name: "test_feature_2", description: "test_feature_2_desc" }, + policy: "permissive", + }, + { + slug: "org-only-feature", + i18n: { title: "org_only_title", name: "org_only", description: "org_only_desc" }, + policy: "permissive", + scope: ["org"], + }, + { + slug: "team-only-feature", + i18n: { title: "team_only_title", name: "team_only", description: "team_only_desc" }, + policy: "permissive", + scope: ["team"], + }, + { + slug: "user-only-feature", + i18n: { title: "user_only_title", name: "user_only", description: "user_only_desc" }, + policy: "permissive", + scope: ["user"], + }, + { + slug: "strict-feature", + i18n: { title: "strict_feature_title", name: "strict_feature", description: "strict_feature_desc" }, + policy: "strict", + }, + ]; return { OPT_IN_FEATURES: mockFeatures, + isOptInFeature: (slug: string) => mockFeatures.some((feature) => feature.slug === slug), getOptInFeaturesForScope: () => mockFeatures, get isFeatureAllowedForScope() { return mockIsFeatureAllowedForScope; }, - getOptInFeatureConfig: (slug: string) => mockFeatures.find((f) => f.slug === slug), + getOptInFeatureConfig: (slug: string) => mockFeatures.find((feature) => feature.slug === slug), }; }); +// Mock MembershipRepository +vi.mock("@calcom/features/membership/repositories/MembershipRepository", () => ({ + MembershipRepository: class { + findAllByUserId(...args: unknown[]) { + return mockFindAllByUserId(...args); + } + }, +})); + +// Mock PermissionCheckService - needs to be a class that can be instantiated +let mockCheckPermission = vi.fn(); +vi.mock("@calcom/features/pbac/services/permission-check.service", () => ({ + PermissionCheckService: class { + checkPermission(...args: unknown[]) { + return mockCheckPermission(...args); + } + }, +})); + +// Mock TeamRepository - needs to be a class that can be instantiated +let mockFindOwnedTeamsByUserId = vi.fn(); +vi.mock("@calcom/features/ee/teams/repositories/TeamRepository", () => ({ + TeamRepository: class { + findOwnedTeamsByUserId(...args: unknown[]) { + return mockFindOwnedTeamsByUserId(...args); + } + }, +})); + +// Mock prisma +vi.mock("@calcom/prisma", () => ({ + prisma: {}, +})); + describe("FeatureOptInService", () => { let mockFeaturesRepository: { getAllFeatures: ReturnType; @@ -81,7 +145,14 @@ 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", "org-only-feature", "team-only-feature", "user-only-feature"], + featureIds: [ + "test-feature-1", + "test-feature-2", + "org-only-feature", + "team-only-feature", + "user-only-feature", + "strict-feature", + ], }); }); @@ -116,14 +187,19 @@ 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", "org-only-feature", "team-only-feature", "user-only-feature"], + featureIds: [ + "test-feature-1", + "test-feature-2", + "org-only-feature", + "team-only-feature", + "user-only-feature", + "strict-feature", + ], }); }); it("should return inherit for org state when parent org has no explicit state", async () => { - mockFeaturesRepository.getAllFeatures.mockResolvedValue([ - { slug: "test-feature-1", enabled: true }, - ]); + mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); // Team ID is 1, Parent Org ID is 100, but org has no explicit state mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({ @@ -159,9 +235,7 @@ describe("FeatureOptInService", () => { }); it("should return inherit for team state when team has no explicit state", async () => { - mockFeaturesRepository.getAllFeatures.mockResolvedValue([ - { slug: "test-feature-1", enabled: true }, - ]); + mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({ "test-feature-1": {}, @@ -179,9 +253,7 @@ describe("FeatureOptInService", () => { }); it("should handle null parentOrgId the same as undefined", async () => { - mockFeaturesRepository.getAllFeatures.mockResolvedValue([ - { slug: "test-feature-1", enabled: true }, - ]); + mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({ "test-feature-1": { 1: "enabled" as FeatureState }, @@ -195,7 +267,14 @@ 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", "org-only-feature", "team-only-feature", "user-only-feature"], + featureIds: [ + "test-feature-1", + "test-feature-2", + "org-only-feature", + "team-only-feature", + "user-only-feature", + "strict-feature", + ], }); }); }); @@ -405,4 +484,236 @@ describe("FeatureOptInService", () => { expect(mockFeaturesRepository.setTeamFeatureState).not.toHaveBeenCalled(); }); }); + + describe("checkFeatureOptInEligibility", () => { + beforeEach(async () => { + vi.resetAllMocks(); + mockFindAllByUserId.mockReset(); + mockCheckPermission = vi.fn(); + mockFindOwnedTeamsByUserId = vi.fn(); + + mockFeaturesRepository = { + getAllFeatures: vi.fn(), + getTeamsFeatureStates: vi.fn(), + getUserFeatureStates: vi.fn().mockResolvedValue({}), + getUserAutoOptIn: vi.fn().mockResolvedValue(false), + getTeamsAutoOptIn: vi.fn().mockResolvedValue({}), + }; + + service = new FeatureOptInService(mockFeaturesRepository as unknown as FeaturesRepository); + }); + + it("should return invalid_feature for non-opt-in features", async () => { + const result = await service.checkFeatureOptInEligibility({ + userId: 1, + featureId: "non-existent-feature", + }); + + expect(result).toEqual({ + status: "invalid_feature", + canOptIn: false, + userRoleContext: null, + blockingReason: null, + }); + }); + + it("should return feature_disabled when feature is globally disabled", async () => { + mockFindAllByUserId.mockResolvedValue([]); + mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: false }]); + mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({}); + + const result = await service.checkFeatureOptInEligibility({ + userId: 1, + featureId: "test-feature-1", + }); + + expect(result).toEqual({ + status: "feature_disabled", + canOptIn: false, + userRoleContext: null, + blockingReason: "feature_global_disabled", + }); + }); + + it("should return already_enabled when feature is already enabled for user", async () => { + mockFindAllByUserId.mockResolvedValue([]); + mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); + mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({}); + mockFeaturesRepository.getUserFeatureStates.mockResolvedValue({ + "test-feature-1": "enabled" as FeatureState, + }); + mockFindOwnedTeamsByUserId.mockResolvedValue([]); + + const result = await service.checkFeatureOptInEligibility({ + userId: 1, + featureId: "test-feature-1", + }); + + expect(result).toEqual({ + status: "already_enabled", + canOptIn: false, + userRoleContext: null, + blockingReason: null, + }); + }); + + it("should return blocked when feature is blocked by org", async () => { + mockFindAllByUserId.mockResolvedValue([ + { teamId: 100, role: "MEMBER", team: { id: 100, isOrganization: true, parentId: null } }, + ]); + mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); + mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({ + "test-feature-1": { 100: "disabled" as FeatureState }, + }); + mockFindOwnedTeamsByUserId.mockResolvedValue([]); + + const result = await service.checkFeatureOptInEligibility({ + userId: 1, + featureId: "test-feature-1", + }); + + expect(result.status).toBe("blocked"); + expect(result.canOptIn).toBe(false); + expect(result.blockingReason).toBe("feature_org_disabled"); + expect(result.userRoleContext).toMatchObject({ + orgId: 100, + adminTeamIds: [], + adminTeamNames: [], + }); + }); + + it("should return can_opt_in when user can opt in", async () => { + mockFindAllByUserId.mockResolvedValue([ + { teamId: 1, role: "MEMBER", team: { id: 1, isOrganization: false, parentId: null } }, + ]); + mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); + mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({}); + mockFindOwnedTeamsByUserId.mockResolvedValue([]); + + const result = await service.checkFeatureOptInEligibility({ + userId: 1, + featureId: "test-feature-1", + }); + + expect(result.status).toBe("can_opt_in"); + expect(result.canOptIn).toBe(true); + expect(result.userRoleContext).toMatchObject({ + orgId: null, + adminTeamIds: [], + adminTeamNames: [], + }); + expect(result.blockingReason).toBeNull(); + }); + + it("should include admin teams in user role context", async () => { + mockFindAllByUserId.mockResolvedValue([ + { teamId: 1, role: "ADMIN", team: { id: 1, isOrganization: false, parentId: null } }, + { teamId: 2, role: "MEMBER", team: { id: 2, isOrganization: false, parentId: null } }, + ]); + mockFeaturesRepository.getAllFeatures.mockResolvedValue([{ slug: "test-feature-1", enabled: true }]); + mockFeaturesRepository.getTeamsFeatureStates.mockResolvedValue({}); + mockFindOwnedTeamsByUserId.mockResolvedValue([{ id: 1, name: "Team 1", isOrganization: false }]); + + const result = await service.checkFeatureOptInEligibility({ + userId: 1, + featureId: "test-feature-1", + }); + + expect(result.status).toBe("can_opt_in"); + expect(result.userRoleContext?.adminTeamIds).toEqual([1]); + expect(result.userRoleContext?.adminTeamNames).toEqual([{ id: 1, name: "Team 1" }]); + }); + }); + + describe("checkFeatureOptInEligibility - simulation logic", () => { + let mockMembershipRepository: { findAllByUserId: ReturnType }; + let mockPermissionCheckService: { checkPermission: ReturnType }; + let mockTeamRepository: { findOwnedTeamsByUserId: ReturnType }; + + beforeEach(() => { + mockMembershipRepository = { + findAllByUserId: vi.fn(), + }; + mockPermissionCheckService = { + checkPermission: vi.fn(), + }; + mockTeamRepository = { + findOwnedTeamsByUserId: vi.fn(), + }; + + vi.doMock("@calcom/features/membership/repositories/MembershipRepository", () => ({ + MembershipRepository: class { + findAllByUserId(...args: unknown[]) { + return mockMembershipRepository.findAllByUserId(...args); + } + }, + })); + vi.doMock("@calcom/features/pbac/services/permission-check.service", () => ({ + PermissionCheckService: vi.fn(() => mockPermissionCheckService), + })); + vi.doMock("@calcom/features/ee/teams/repositories/TeamRepository", () => ({ + TeamRepository: vi.fn(() => mockTeamRepository), + })); + }); + + it("should return blocked when strict policy feature would not be enabled even after user opts in", async () => { + const { computeEffectiveStateAcrossTeams } = await import("../lib/computeEffectiveState"); + + const result = computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "inherit", + teamStates: ["inherit"], + userState: "enabled", + policy: "strict", + }); + + expect(result.enabled).toBe(false); + expect(result.reason).toBe("feature_user_only_not_allowed"); + }); + + it("should return enabled when permissive policy feature would be enabled after user opts in", async () => { + const { computeEffectiveStateAcrossTeams } = await import("../lib/computeEffectiveState"); + + const result = computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "inherit", + teamStates: ["inherit"], + userState: "enabled", + policy: "permissive", + }); + + expect(result.enabled).toBe(true); + expect(result.reason).toBe("feature_enabled"); + }); + + it("should return enabled when strict policy feature has org enablement and user opts in", async () => { + const { computeEffectiveStateAcrossTeams } = await import("../lib/computeEffectiveState"); + + const result = computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "enabled", + teamStates: ["inherit"], + userState: "enabled", + policy: "strict", + }); + + expect(result.enabled).toBe(true); + expect(result.reason).toBe("feature_enabled"); + }); + + it("should return blocked when strict policy feature has any team disabled", async () => { + const { computeEffectiveStateAcrossTeams } = await import("../lib/computeEffectiveState"); + + const result = computeEffectiveStateAcrossTeams({ + globalEnabled: true, + orgState: "enabled", + teamStates: ["disabled", "inherit"], + userState: "enabled", + policy: "strict", + }); + + expect(result.enabled).toBe(false); + expect(result.reason).toBe("feature_any_team_disabled"); + }); + }); }); diff --git a/packages/features/feature-opt-in/services/FeatureOptInService.ts b/packages/features/feature-opt-in/services/FeatureOptInService.ts index 67d714a069..c33ed1ee47 100644 --- a/packages/features/feature-opt-in/services/FeatureOptInService.ts +++ b/packages/features/feature-opt-in/services/FeatureOptInService.ts @@ -1,16 +1,28 @@ +import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import type { FeatureId, FeatureState } from "@calcom/features/flags/config"; import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { ErrorWithCode } from "@calcom/lib/errors"; +import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; -import { getOptInFeatureConfig, getOptInFeaturesForScope, isFeatureAllowedForScope } from "../config"; +import { + getOptInFeatureConfig, + getOptInFeaturesForScope, + isFeatureAllowedForScope, + isOptInFeature, +} from "../config"; import { applyAutoOptIn } from "../lib/applyAutoOptIn"; import { computeEffectiveStateAcrossTeams } from "../lib/computeEffectiveState"; import type { OptInFeaturePolicy, OptInFeatureScope } from "../types"; import type { EffectiveStateReason, + FeatureOptInEligibilityResult, IFeatureOptInService, ResolvedFeatureState, + UserRoleContext, } from "./IFeatureOptInService"; type ListFeaturesForUserResult = { @@ -33,6 +45,13 @@ type ListFeaturesForTeamResult = { orgState: FeatureState; }; +type FeatureOptInEligibilityStatus = + | "invalid_feature" + | "feature_disabled" + | "already_enabled" + | "blocked" + | "can_opt_in"; + function getOrgState(orgId: number | null, teamStatesById: Record): FeatureState { if (orgId !== null) { return teamStatesById[orgId] ?? "inherit"; @@ -100,7 +119,10 @@ export class FeatureOptInService implements IFeatureOptInService { teamIds: number[]; featureIds: FeatureId[]; }): Promise> { - const allTeamIds = orgId !== null ? [orgId, ...teamIds] : teamIds; + let allTeamIds = teamIds; + if (orgId !== null) { + allTeamIds = [orgId, ...teamIds]; + } const [allFeatures, allTeamStates, userStates, userAutoOptIn, teamsAutoOptIn] = await Promise.all([ this.featuresRepository.getAllFeatures(), @@ -242,6 +264,167 @@ export class FeatureOptInService implements IFeatureOptInService { return results.filter((result) => result.globalEnabled); } + /** + * Check if user is eligible to see the feature opt-in banner. + * Uses PBAC to determine user's role context (org admin, team admin permissions). + */ + async checkFeatureOptInEligibility(input: { + userId: number; + featureId: string; + }): Promise { + const { userId, featureId } = input; + + if (!isOptInFeature(featureId)) { + return { + status: "invalid_feature", + canOptIn: false, + userRoleContext: null, + blockingReason: null, + }; + } + + const { orgId, teamIds } = await this.getUserOrgAndTeamIds(userId); + const resolvedStates = await this.resolveFeatureStatesAcrossTeams({ + userId, + orgId, + teamIds, + featureIds: [featureId], + }); + + const featureState = resolvedStates[featureId]; + if (!featureState) { + return { + status: "invalid_feature", + canOptIn: false, + userRoleContext: null, + blockingReason: null, + }; + } + + if (!featureState.globalEnabled) { + return { + status: "feature_disabled", + canOptIn: false, + userRoleContext: null, + blockingReason: "feature_global_disabled", + }; + } + + if (featureState.effectiveEnabled) { + return { + status: "already_enabled", + canOptIn: false, + userRoleContext: null, + blockingReason: null, + }; + } + + const userRoleContext = await this.getUserRoleContext(userId, orgId, teamIds); + + const blockingReasons = [ + "feature_org_disabled", + "feature_all_teams_disabled", + "feature_any_team_disabled", + "feature_user_only_not_allowed", + ]; + + if (blockingReasons.includes(featureState.effectiveReason)) { + return { + status: "blocked", + canOptIn: false, + userRoleContext, + blockingReason: featureState.effectiveReason, + }; + } + + // Simulate what would happen if user opts in + const featureConfig = getOptInFeatureConfig(featureId); + if (!featureConfig) { + return { + status: "blocked", + canOptIn: false, + userRoleContext, + blockingReason: "feature_config_not_found", + }; + } + const policy: OptInFeaturePolicy = featureConfig.policy ?? "permissive"; + + const simulatedResult = computeEffectiveStateAcrossTeams({ + globalEnabled: featureState.globalEnabled, + orgState: featureState.orgState, + teamStates: featureState.teamStates, + userState: "enabled", + policy, + }); + + // For strict policy features, user opt-in alone won't enable the feature if org/team hasn't explicitly enabled it. + // E.g., strict policy + org "inherit" + team "inherit" + user "enabled" → feature still not enabled. + if (!simulatedResult.enabled) { + return { + status: "blocked", + canOptIn: false, + userRoleContext, + blockingReason: simulatedResult.reason, + }; + } + + return { + status: "can_opt_in", + canOptIn: true, + userRoleContext, + blockingReason: null, + }; + } + + private async getUserOrgAndTeamIds(userId: number): Promise<{ orgId: number | null; teamIds: number[] }> { + const membershipRepository = new MembershipRepository(prisma); + const memberships = await membershipRepository.findAllByUserId({ + userId, + filters: { accepted: true }, + }); + + let orgId: number | null = null; + const teamIds: number[] = []; + + for (const membership of memberships) { + if (membership.team.isOrganization) { + orgId = membership.teamId; + } else { + teamIds.push(membership.teamId); + } + } + + return { orgId, teamIds }; + } + + private async getUserRoleContext( + userId: number, + orgId: number | null, + _teamIds: number[] + ): Promise { + const permissionService = new PermissionCheckService(); + const fallbackRoles = [MembershipRole.OWNER, MembershipRole.ADMIN]; + + let isOrgAdmin = false; + if (orgId !== null) { + isOrgAdmin = await permissionService.checkPermission({ + userId, + teamId: orgId, + permission: "organization.update", + fallbackRoles, + }); + } + + const teamRepository = new TeamRepository(prisma); + const adminTeams = await teamRepository.findOwnedTeamsByUserId({ userId }); + + const nonOrgAdminTeams = adminTeams.filter((team) => !team.isOrganization); + const adminTeamIds = nonOrgAdminTeams.map((team) => team.id); + const adminTeamNames = nonOrgAdminTeams.map((team) => ({ id: team.id, name: team.name })); + + return { isOrgAdmin, orgId, adminTeamIds, adminTeamNames }; + } + /** * Set user's feature state. * Delegates to FeaturesRepository.setUserFeatureState. @@ -281,7 +464,13 @@ export class FeatureOptInService implements IFeatureOptInService { */ async setTeamFeatureState( input: - | { teamId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number; scope?: OptInFeatureScope } + | { + 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; @@ -307,3 +496,6 @@ export class FeatureOptInService implements IFeatureOptInService { } } } + +export type { FeatureOptInEligibilityStatus }; +export type { UserRoleContext, FeatureOptInEligibilityResult } from "./IFeatureOptInService"; diff --git a/packages/features/feature-opt-in/services/IFeatureOptInService.ts b/packages/features/feature-opt-in/services/IFeatureOptInService.ts index e75b8f08f5..97e419006f 100644 --- a/packages/features/feature-opt-in/services/IFeatureOptInService.ts +++ b/packages/features/feature-opt-in/services/IFeatureOptInService.ts @@ -5,6 +5,45 @@ import type { OptInFeatureScope } from "../types"; export type { EffectiveStateReason }; +export type UserRoleContext = { + isOrgAdmin: boolean; + orgId: number | null; + adminTeamIds: number[]; + adminTeamNames: { id: number; name: string }[]; +}; + +export type FeatureOptInEligibilityResult = + | { + status: "invalid_feature"; + canOptIn: false; + userRoleContext: null; + blockingReason: null; + } + | { + status: "feature_disabled"; + canOptIn: false; + userRoleContext: null; + blockingReason: string; + } + | { + status: "already_enabled"; + canOptIn: false; + userRoleContext: null; + blockingReason: null; + } + | { + status: "blocked"; + canOptIn: false; + userRoleContext: UserRoleContext; + blockingReason: string; + } + | { + status: "can_opt_in"; + canOptIn: true; + userRoleContext: UserRoleContext; + blockingReason: null; + }; + export type ResolvedFeatureState = { featureId: FeatureId; globalEnabled: boolean; @@ -44,7 +83,17 @@ export interface IFeatureOptInService { ): Promise; setTeamFeatureState( input: - | { teamId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number; scope?: OptInFeatureScope } + | { + teamId: number; + featureId: FeatureId; + state: "enabled" | "disabled"; + assignedBy: number; + scope?: OptInFeatureScope; + } | { teamId: number; featureId: FeatureId; state: "inherit"; scope?: OptInFeatureScope } ): Promise; + checkFeatureOptInEligibility(input: { + userId: number; + featureId: string; + }): Promise; } diff --git a/packages/features/package.json b/packages/features/package.json index 268d7d9e29..1e9cbf29b9 100644 --- a/packages/features/package.json +++ b/packages/features/package.json @@ -19,6 +19,7 @@ "@calcom/lib": "workspace:*", "@calcom/trpc": "workspace:*", "@calcom/ui": "workspace:*", + "@coss/ui": "workspace:*", "@evyweb/ioctopus": "1.2.0", "@tanstack/react-table": "8.20.6", "@tanstack/react-virtual": "3.10.9", diff --git a/packages/trpc/server/routers/viewer/featureOptIn/_router.ts b/packages/trpc/server/routers/viewer/featureOptIn/_router.ts index 53a34c6d11..884c3c6cef 100644 --- a/packages/trpc/server/routers/viewer/featureOptIn/_router.ts +++ b/packages/trpc/server/routers/viewer/featureOptIn/_router.ts @@ -1,30 +1,28 @@ -import type { ZodEnum } from "zod"; -import { z } from "zod"; - import { getFeatureOptInService } from "@calcom/features/di/containers/FeatureOptInService"; import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import { isOptInFeature } from "@calcom/features/feature-opt-in/config"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; import { prisma } from "@calcom/prisma"; - import { TRPCError } from "@trpc/server"; +import type { ZodEnum } from "zod"; +import { z } from "zod"; import authedProcedure from "../../../procedures/authedProcedure"; -import { router } from "../../../trpc"; import { createOrgPbacProcedure, createTeamPbacProcedure } from "../../../procedures/pbacProcedures"; +import { router } from "../../../trpc"; -const featureStateSchema: ZodEnum<["enabled", "disabled", "inherit"]> = z.enum(["enabled", "disabled", "inherit"]); +const featureStateSchema: ZodEnum<["enabled", "disabled", "inherit"]> = z.enum([ + "enabled", + "disabled", + "inherit", +]); -const featureOptInService = getFeatureOptInService(); -const featuresRepository = new FeaturesRepository(prisma); -const teamRepository = new TeamRepository(prisma); -const membershipRepository = new MembershipRepository(prisma); +const featureOptInService: ReturnType = getFeatureOptInService(); +const featuresRepository: FeaturesRepository = new FeaturesRepository(prisma); +const teamRepository: TeamRepository = new TeamRepository(prisma); +const membershipRepository: MembershipRepository = new MembershipRepository(prisma); -/** - * Helper to get user's org and team IDs from their memberships. - * Returns orgId (if user belongs to an org) and teamIds (non-org teams). - */ async function getUserOrgAndTeamIds(userId: number): Promise<{ orgId: number | null; teamIds: number[] }> { const memberships = await membershipRepository.findAllByUserId({ userId, @@ -83,6 +81,19 @@ export const featureOptInRouter = router({ return featureOptInService.listFeaturesForTeam({ teamId: ctx.organizationId, scope: "org" }); }), + checkFeatureOptInEligibility: authedProcedure + .input( + z.object({ + featureId: z.string(), + }) + ) + .query(async ({ ctx, input }) => { + return featureOptInService.checkFeatureOptInEligibility({ + userId: ctx.user.id, + featureId: input.featureId, + }); + }), + /** * Set user's feature state. */ diff --git a/yarn.lock b/yarn.lock index 9eec504e09..60c14f4084 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2602,6 +2602,7 @@ __metadata: "@calcom/testing": "workspace:*" "@calcom/trpc": "workspace:*" "@calcom/ui": "workspace:*" + "@coss/ui": "workspace:*" "@evyweb/ioctopus": "npm:1.2.0" "@tanstack/react-table": "npm:8.20.6" "@tanstack/react-virtual": "npm:3.10.9"