f4248bf20d
* 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>
233 lines
7.5 KiB
TypeScript
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}`,
|
|
});
|
|
}
|
|
}
|
|
}
|