Files
calendar/packages/features/feature-opt-in/services/FeatureOptInService.ts
T
Eunjae LeeGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
f4248bf20d feat: implement FeatureOptInService (#25805)
* feat: implement FeatureOptInService WIP

* clean up

* feat: consolidate feature repositories and add updateFeatureForUser

- Implement updateFeatureForUser in FeaturesRepository (similar to updateFeatureForTeam)
- Move getUserFeatureState and getTeamFeatureState from PrismaFeatureOptInRepository to FeaturesRepository
- Update FeatureOptInService to use only FeaturesRepository
- Add setUserFeatureState and setTeamFeatureState methods to FeatureOptInService
- Update _router.ts to remove PrismaFeatureOptInRepository usage
- Remove PrismaFeatureOptInRepository.ts and FeatureOptInRepositoryInterface.ts
- Update features.repository.interface.ts and features.repository.mock.ts
- Add integration tests for updateFeatureForUser, getUserFeatureState, getTeamFeatureState
- Update service.integration-test.ts to use FeaturesRepository

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

* refactor: rename updateFeatureForUser to setUserFeatureState

Rename to match the convention used for setTeamFeatureState

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

* refactor: return FeatureState type from getUserFeatureState and getTeamFeatureState

* fix integration tests

* clean up logics

* update services and router

* refactor: change getUserFeatureState and getTeamFeatureState to accept featureIds array

- Renamed getUserFeatureState to getUserFeatureStates
- Renamed getTeamFeatureState to getTeamFeatureStates
- Changed parameter from featureId: string to featureIds: string[]
- Changed return type from FeatureState to Record<string, FeatureState>
- Updated FeatureOptInService to use the new batch methods
- Added tests for querying multiple features in a single call
- Optimized listFeaturesForTeam to fetch all feature states in one query

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

* feat: add getFeatureStateForTeams for batch querying multiple teams

- Added getFeatureStateForTeams method to query a single feature across multiple teams in one call
- Updated FeatureOptInService.resolveFeatureStateAcrossTeams to use the new batch method
- Replaces N+1 queries with a single database query for team states
- Added comprehensive integration tests for the new method

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

* refactor: combine org and team state queries into single call

- Include orgId in the teamIds array passed to getFeatureStateForTeams
- Extract org state and team states from the combined result
- Reduces database queries from 3 to 2 in resolveFeatureStateAcrossTeams

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

* refactor: use team.isOrganization and clarify computeEffectiveState comment

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

* refactor: use MembershipRepository.findAllByUserId with isOrganization

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

* feat: add featureId validation using isOptInFeature type guard

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

* less queries

* add fallback value

* fix type error

* move files

* add autoOptInFeatures column

* use autoOptInFeatures flag within FeatureOptInService

* add setUserAutoOptIn and setTeamAutoOptIn

* fix computeEffectiveState logic

* rewrite computeEffectiveState

* clean up integration tests

* clean up in afterEach

* fix type error

* refactor: use FeaturesRepository methods instead of direct Prisma calls

Replace all manual userFeatures and teamFeatures Prisma operations with
the new setUserFeatureState and setTeamFeatureState repository methods.

Changes include:
- Admin handlers (assignFeatureToTeam, unassignFeatureFromTeam)
- Test fixtures and integration tests
- Playwright fixtures
- Development scripts

This ensures consistent feature flag management through the repository
pattern and supports the new tri-state semantics (enabled/disabled/inherit).

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

* clean up

* fix the logic

* extract some logic into applyAutoOptIn()

* remove wrong code

* refactor: convert setUserFeatureState and setTeamFeatureState to object params with discriminated union

- Convert multiple positional parameters to single object parameter
- Use discriminated union types: assignedBy required for enabled/disabled, omitted for inherit
- Update all callers across repository, service, handlers, fixtures, and tests

* fix type error

* use Promise.all

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-06 16:55:53 +01:00

233 lines
7.5 KiB
TypeScript

import type { FeatureId, FeatureState } from "@calcom/features/flags/config";
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { OPT_IN_FEATURES } from "../config";
import { applyAutoOptIn } from "../lib/applyAutoOptIn";
import { computeEffectiveStateAcrossTeams } from "../lib/computeEffectiveState";
type ResolvedFeatureState = {
featureId: FeatureId;
globalEnabled: boolean;
orgState: FeatureState; // Raw state (before auto-opt-in transform)
teamStates: FeatureState[]; // Raw states
userState: FeatureState | undefined; // Raw state
effectiveEnabled: boolean;
// Auto-opt-in flags for UI to show checkbox state
orgAutoOptIn: boolean;
teamAutoOptIns: boolean[];
userAutoOptIn: boolean;
};
/**
* Service class for managing feature opt-in logic.
* Computes effective states based on global, org, team, and user settings.
*/
export class FeatureOptInService {
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>> {
// Get org and team states in a single query
// Include orgId in the query if it exists
const allTeamIds = orgId !== null ? [orgId, ...teamIds] : 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 globalEnabled = globalEnabledMap.get(featureId) ?? false;
const teamStatesById = allTeamStates[featureId] ?? {};
// Extract raw org state from the combined result
const orgState: FeatureState = orgId !== null ? teamStatesById[orgId] ?? "inherit" : "inherit";
// Extract raw team states from the combined result
const teamStates = teamIds.map((teamId) => teamStatesById[teamId] ?? "inherit");
const userState = userStates[featureId] ?? "inherit";
// Get auto-opt-in flags for this feature's hierarchy
const orgAutoOptIn = orgId !== null ? teamsAutoOptIn[orgId] ?? false : false;
const teamAutoOptIns = teamIds.map((teamId) => teamsAutoOptIn[teamId] ?? false);
// Apply auto-opt-in transformation
const { effectiveOrgState, effectiveTeamStates, effectiveUserState } = applyAutoOptIn({
orgState,
teamStates,
userState,
orgAutoOptIn,
teamAutoOptIns,
userAutoOptIn,
});
// Compute effective state with transformed states
const effectiveEnabled = computeEffectiveStateAcrossTeams({
globalEnabled,
orgState: effectiveOrgState,
teamStates: effectiveTeamStates,
userState: effectiveUserState,
});
resolvedStates[featureId] = {
featureId,
globalEnabled,
orgState, // Raw state (before auto-opt-in transform)
teamStates, // Raw states
userState, // Raw state
effectiveEnabled,
// Auto-opt-in flags for UI
orgAutoOptIn,
teamAutoOptIns,
userAutoOptIn,
};
}
return resolvedStates;
}
/**
* List all opt-in features with their states for a user across teams.
* Only returns features that are in the allowlist and globally enabled.
*/
async listFeaturesForUser(input: { userId: number; orgId: number | null; teamIds: number[] }) {
const { userId, orgId, teamIds } = input;
const featureIds = OPT_IN_FEATURES.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.
* Used for team admin settings page to configure feature opt-in.
* Only returns features that are in the allowlist and globally enabled.
*/
async listFeaturesForTeam(input: { teamId: number }) {
const { teamId } = input;
const [allFeatures, teamStates] = await Promise.all([
this.featuresRepository.getAllFeatures(),
// Get all team feature states in a single query
this.featuresRepository.getTeamsFeatureStates({
teamIds: [teamId],
featureIds: OPT_IN_FEATURES.map((config) => config.slug),
}),
]);
const results = OPT_IN_FEATURES.map((config) => {
const globalFeature = allFeatures.find((f) => f.slug === config.slug);
const globalEnabled = globalFeature?.enabled ?? false;
const teamState = teamStates[config.slug]?.[teamId] ?? "inherit";
return {
featureId: config.slug,
globalEnabled,
teamState,
};
});
return results.filter((result) => result.globalEnabled);
}
/**
* Set user's feature state.
* Delegates to FeaturesRepository.setUserFeatureState.
*/
async setUserFeatureState(
input:
| { userId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number }
| { userId: number; featureId: FeatureId; state: "inherit" }
) {
const { userId, featureId, state } = input;
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.
*/
async setTeamFeatureState(
input:
| { teamId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: number }
| { teamId: number; featureId: FeatureId; state: "inherit" }
) {
const { teamId, featureId, state } = input;
if (state === "inherit") {
await this.featuresRepository.setTeamFeatureState({
teamId,
featureId,
state,
});
} else {
const { assignedBy } = input;
await this.featuresRepository.setTeamFeatureState({
teamId,
featureId,
state,
assignedBy: `user-${assignedBy}`,
});
}
}
}