Files
cal.diy/packages/features/feature-opt-in/services/FeatureOptInService.ts
T
adc198b37b feat: add feature opt-in banner system (#26625)
* docs: add feature opt-in banner system plan

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* feat: add i18n translation keys for feature opt-in banner

* refactor: create FeatureOptInBannerWrapper for simpler consumer integration

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: use @calcom/lib/webstorage and add Zod schema for localStorage

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: address PR review comments - use PBAC, move logic to service, improve typing

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

# Conflicts:
#	packages/features/feature-opt-in/services/FeatureOptInService.ts

* test: add comprehensive tests for checkFeatureOptInEligibility method

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: correct import and Checkbox type errors

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* fix: add early return for missing featureConfig and clarify simulation comment

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix repository usage

* refactor: simplify FeatureOptInConfirmDialog UI and remove unused translation

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: replace radio buttons with multi-select dropdown in FeatureOptInConfirmDialog

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* resize banner

* refactor: use AnimatedPopover pattern for team selection and rename titleI18nKey to nameI18nKey

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: change 'Just for me' to 'For me', fix width clipping, remove divider

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* update style

* feat: make 'Just for me' mutually exclusive with teams/org and store opt-ins in localStorage

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: remove barrel imports and update to direct imports

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: add modal prop to AnimatedPopover to fix scroll lock conflict in dialogs

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: wrap Popover.Content with Portal to prevent layout interference in dialogs

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: use flexbox gap instead of space-y to prevent popover wrapper from affecting layout

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* feat: add i18n.title to OptInFeatureConfig type definition

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* fix: don't record dismissed info in local storage after successful opt-in

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: add checkFeatureOptInEligibility to IFeatureOptInService interface

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* 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>
2026-01-19 18:48:26 +00:00

502 lines
15 KiB
TypeScript

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,
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 = {
featureId: FeatureId;
globalEnabled: boolean;
orgState: FeatureState;
teamStates: FeatureState[];
userState: FeatureState | undefined;
effectiveEnabled: boolean;
effectiveReason: EffectiveStateReason;
orgAutoOptIn: boolean;
teamAutoOptIns: boolean[];
userAutoOptIn: boolean;
};
type ListFeaturesForTeamResult = {
featureId: FeatureId;
globalEnabled: boolean;
teamState: FeatureState;
orgState: FeatureState;
};
type FeatureOptInEligibilityStatus =
| "invalid_feature"
| "feature_disabled"
| "already_enabled"
| "blocked"
| "can_opt_in";
function getOrgState(orgId: number | null, teamStatesById: Record<number, FeatureState>): FeatureState {
if (orgId !== null) {
return teamStatesById[orgId] ?? "inherit";
}
return "inherit";
}
function getOrgAutoOptIn(orgId: number | null, teamsAutoOptIn: Record<number, boolean>): boolean {
if (orgId !== null) {
return teamsAutoOptIn[orgId] ?? false;
}
return false;
}
function getTeamIdsToQuery(teamId: number, parentOrgId: number | null | undefined): number[] {
if (parentOrgId) {
return [teamId, parentOrgId];
}
return [teamId];
}
function getOrgStateForTeam(
parentOrgId: number | null | undefined,
teamStates: Record<string, Record<number, FeatureState>>,
slug: FeatureId
): FeatureState {
if (parentOrgId) {
return teamStates[slug]?.[parentOrgId] ?? "inherit";
}
return "inherit";
}
/**
* Service class for managing feature opt-in logic.
* Computes effective states based on global, org, team, and user settings.
*/
export class FeatureOptInService implements IFeatureOptInService {
constructor(private featuresRepository: FeaturesRepository) {}
/**
* Core method: Resolve feature states for a user across all their teams.
*
* Precedence rules:
* 1. Global disabled → false (stop)
* 2. Org explicitly disabled → false (stop)
* 3. Org explicitly enabled → allowed at org level, continue to teams
* 4. Org inherits (or no org) → check teams
* 5. All teams explicitly disabled → false (stop)
* 6. At least one team enabled OR inherits → allowed at team level, continue to user
* 7. User explicitly disabled → false
* 8. User explicitly enabled OR inherits → true
*
* Auto-opt-in transformation:
* - If autoOptInFeatures=true at a level AND state is "inherit", transform to "enabled"
* - This transformation happens before computing effectiveEnabled
*/
async resolveFeatureStatesAcrossTeams({
userId,
orgId,
teamIds,
featureIds,
}: {
userId: number;
orgId: number | null;
teamIds: number[];
featureIds: FeatureId[];
}): Promise<Record<string, ResolvedFeatureState>> {
let allTeamIds = teamIds;
if (orgId !== null) {
allTeamIds = [orgId, ...teamIds];
}
const [allFeatures, allTeamStates, userStates, userAutoOptIn, teamsAutoOptIn] = await Promise.all([
this.featuresRepository.getAllFeatures(),
this.featuresRepository.getTeamsFeatureStates({ teamIds: allTeamIds, featureIds }),
this.featuresRepository.getUserFeatureStates({ userId, featureIds }),
this.featuresRepository.getUserAutoOptIn(userId),
this.featuresRepository.getTeamsAutoOptIn(allTeamIds),
]);
const globalEnabledMap = new Map(allFeatures.map((feature) => [feature.slug, feature.enabled ?? false]));
const resolvedStates: Record<string, ResolvedFeatureState> = {};
for (const featureId of featureIds) {
const state = this.resolveFeatureState(
featureId,
orgId,
teamIds,
globalEnabledMap,
allTeamStates,
userStates,
teamsAutoOptIn,
userAutoOptIn
);
resolvedStates[featureId] = state;
}
return resolvedStates;
}
private resolveFeatureState(
featureId: FeatureId,
orgId: number | null,
teamIds: number[],
globalEnabledMap: Map<string, boolean>,
allTeamStates: Record<string, Record<number, FeatureState>>,
userStates: Record<string, FeatureState>,
teamsAutoOptIn: Record<number, boolean>,
userAutoOptIn: boolean
): ResolvedFeatureState {
const globalEnabled = globalEnabledMap.get(featureId) ?? false;
const teamStatesById = allTeamStates[featureId] ?? {};
const orgState = getOrgState(orgId, teamStatesById);
const teamStates = teamIds.map((teamId) => teamStatesById[teamId] ?? "inherit");
const userState = userStates[featureId] ?? "inherit";
const orgAutoOptIn = getOrgAutoOptIn(orgId, teamsAutoOptIn);
const teamAutoOptIns = teamIds.map((teamId) => teamsAutoOptIn[teamId] ?? false);
const { effectiveOrgState, effectiveTeamStates, effectiveUserState } = applyAutoOptIn({
orgState,
teamStates,
userState,
orgAutoOptIn,
teamAutoOptIns,
userAutoOptIn,
});
// Get the policy for this feature from the config
const featureConfig = getOptInFeatureConfig(featureId);
const policy: OptInFeaturePolicy = featureConfig?.policy ?? "permissive";
const { enabled: effectiveEnabled, reason: effectiveReason } = computeEffectiveStateAcrossTeams({
globalEnabled,
orgState: effectiveOrgState,
teamStates: effectiveTeamStates,
userState: effectiveUserState,
policy,
});
return {
featureId,
globalEnabled,
orgState,
teamStates,
userState,
effectiveEnabled,
effectiveReason,
orgAutoOptIn,
teamAutoOptIns,
userAutoOptIn,
};
}
/**
* List all opt-in features with their states for a user across teams.
* Only returns features that are in the allowlist, globally enabled, and scoped to "user".
*/
async listFeaturesForUser(input: {
userId: number;
orgId: number | null;
teamIds: number[];
}): Promise<ListFeaturesForUserResult[]> {
const { userId, orgId, teamIds } = input;
const userScopedFeatures = getOptInFeaturesForScope("user");
const featureIds = userScopedFeatures.map((config) => config.slug);
const resolvedStates = await this.resolveFeatureStatesAcrossTeams({
userId,
orgId,
teamIds,
featureIds,
});
return featureIds.map((featureId) => resolvedStates[featureId]).filter((state) => state.globalEnabled);
}
/**
* List all opt-in features with their raw states for a team or organization.
* Used for team/org admin settings page to configure feature opt-in.
* Only returns features that are in the allowlist, globally enabled, and scoped to the specified scope.
* If parentOrgId is provided, also returns the organization state for each feature.
*/
async listFeaturesForTeam(input: {
teamId: number;
parentOrgId?: number | null;
scope?: OptInFeatureScope;
}): Promise<ListFeaturesForTeamResult[]> {
const { teamId, parentOrgId, scope = "team" } = input;
const teamIdsToQuery = getTeamIdsToQuery(teamId, parentOrgId);
const scopedFeatures = getOptInFeaturesForScope(scope);
const [allFeatures, teamStates] = await Promise.all([
this.featuresRepository.getAllFeatures(),
this.featuresRepository.getTeamsFeatureStates({
teamIds: teamIdsToQuery,
featureIds: scopedFeatures.map((config) => config.slug),
}),
]);
const results = scopedFeatures.map((config) => {
const globalFeature = allFeatures.find((f) => f.slug === config.slug);
const globalEnabled = globalFeature?.enabled ?? false;
const teamState = teamStates[config.slug]?.[teamId] ?? "inherit";
const orgState = getOrgStateForTeam(parentOrgId, teamStates, config.slug);
return { featureId: config.slug, globalEnabled, teamState, orgState };
});
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<FeatureOptInEligibilityResult> {
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<UserRoleContext> {
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.
* Throws an error if the feature is not scoped to "user".
*/
async setUserFeatureState(
input:
| { userId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number }
| { userId: number; featureId: FeatureId; state: "inherit" }
): Promise<void> {
const { userId, featureId, state } = input;
if (!isFeatureAllowedForScope(featureId, "user")) {
throw new ErrorWithCode(
ErrorCode.BadRequest,
`Feature "${featureId}" is not available at the user scope`
);
}
if (state === "inherit") {
await this.featuresRepository.setUserFeatureState({ userId, featureId, state });
} else {
const { assignedBy } = input;
await this.featuresRepository.setUserFeatureState({
userId,
featureId,
state,
assignedBy: `user-${assignedBy}`,
});
}
}
/**
* Set team's feature state.
* Delegates to FeaturesRepository.setTeamFeatureState.
* Throws an error if the feature is not scoped to the specified scope.
*/
async setTeamFeatureState(
input:
| {
teamId: number;
featureId: FeatureId;
state: "enabled" | "disabled";
assignedBy: number;
scope?: OptInFeatureScope;
}
| { teamId: number; featureId: FeatureId; state: "inherit"; scope?: OptInFeatureScope }
): Promise<void> {
const { teamId, featureId, state } = input;
const scope = input.scope ?? "team";
if (!isFeatureAllowedForScope(featureId, scope)) {
throw new ErrorWithCode(
ErrorCode.BadRequest,
`Feature "${featureId}" is not available at the ${scope} scope`
);
}
if (state === "inherit") {
await this.featuresRepository.setTeamFeatureState({ teamId, featureId, state });
} else {
const { assignedBy } = input;
await this.featuresRepository.setTeamFeatureState({
teamId,
featureId,
state,
assignedBy: `user-${assignedBy}`,
});
}
}
}
export type { FeatureOptInEligibilityStatus };
export type { UserRoleContext, FeatureOptInEligibilityResult } from "./IFeatureOptInService";