29ec2b9cb6
* refactor: split flag repositories into Prisma and Cached layers - Rename FeatureRepository to PrismaFeatureRepository (raw DB access) - Rename TeamFeatureRepository to PrismaTeamFeatureRepository (raw DB access) - Rename UserFeatureRepository to PrismaUserFeatureRepository (raw DB access) - Create CachedFeatureRepository with @Memoize wrapping PrismaFeatureRepository - Create CachedTeamFeatureRepository with @Memoize/@Unmemoize wrapping PrismaTeamFeatureRepository - Create CachedUserFeatureRepository with @Memoize/@Unmemoize wrapping PrismaUserFeatureRepository - Update DI tokens, modules, and containers for all 6 repositories - Update imports in FeatureOptInService and related modules - Update tests to use new repository structure Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: simplify @Memoize key patterns and delegate batch methods to Prisma - Use direct function references for @Memoize key (e.g., KEY.all instead of () => KEY.all()) - Simplify batch methods in Cached repositories to delegate to Prisma repository - Update tests to reflect the new delegation pattern Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: add orderBy to TeamRepository.findAllByParentId for deterministic results Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * test: update TeamRepository test to expect orderBy in findAllByParentId Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: cleanup features repository and add specialized repository methods (#27195) * refactor: cleanup features repository and add findBySlug, update methods - Remove unused methods from FeaturesRepository (keep getTeamsWithFeatureEnabled) - Add findAll(), findBySlug(), update() to IFeatureRepository interface - Add findAll() with caching to CachedFeatureRepository - Add findBySlug() with caching to CachedFeatureRepository - Add update() with Unmemoize to CachedFeatureRepository - Add checkIfFeatureIsEnabledGlobally() to CachedFeatureRepository - Update toggleFeatureFlag.handler.ts to use repository instead of raw Prisma - Add comprehensive unit tests for all new methods Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: update updatedAt timestamp in feature update method Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: move feature check methods to specialized repositories - Replace getUserFeaturesStatus with two checkIfUserHasFeature calls in bookings page - Move checkIfTeamHasFeature to PrismaTeamFeatureRepository with pass-through in CachedTeamFeatureRepository - Move checkIfUserHasFeature and checkIfUserHasFeatureNonHierarchical to PrismaUserFeatureRepository with pass-throughs in CachedUserFeatureRepository - Add getEnabledFeatures to PrismaTeamFeatureRepository with caching in CachedTeamFeatureRepository - Keep FeaturesRepository methods as pass-throughs for backward compatibility - Update test to expect updatedAt in feature update Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: remove getUserFeaturesStatus and unused methods from FeaturesRepository Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * restore comment * fix: invalidate all-features cache on update and enabledFeatures cache on upsert/delete - CachedFeatureRepository: Add KEY.all() to @Unmemoize keys in update() to prevent stale findAll() results - CachedTeamFeatureRepository: Add KEY.enabledFeatures(teamId) to @Unmemoize keys in upsert() and delete() to prevent stale getEnabledFeatures() results Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test: add CachedUserFeatureRepository tests Add comprehensive tests for CachedUserFeatureRepository covering: - findByUserIdAndFeatureId (cache hit, cache miss, not found) - findByUserIdAndFeatureIds (empty input, multiple features) - upsert (with cache invalidation) - delete (with cache invalidation) - findAutoOptInByUserId (cache hit, cache miss, not found) - setAutoOptIn (with cache invalidation) Co-Authored-By: unknown <> * test: remove tests for methods removed from FeaturesRepository Remove integration tests for methods that were intentionally removed: - getUserFeatureStates - getTeamsFeatureStates - getUserAutoOptIn - getTeamsAutoOptIn - setUserAutoOptIn - setTeamAutoOptIn Co-Authored-By: unknown <> * avoid N+1 query * refactor: add select clauses to PrismaFeatureRepository queries - Add explicit select clauses to findAll, findBySlug, and update methods - Only fetch fields needed for FeatureDto (slug, enabled, description, type, stale, lastUsedAt, createdAt, updatedAt, updatedBy) - Update tests to expect select clauses - Fix UserFeatureRepository test to use findMany mock Co-Authored-By: unknown <> * fix bad conflict resolved * use userId --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
524 lines
17 KiB
TypeScript
524 lines
17 KiB
TypeScript
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
|
import type { FeatureId, FeatureState } from "@calcom/features/flags/config";
|
|
import type { IFeatureRepository } from "@calcom/features/flags/repositories/PrismaFeatureRepository";
|
|
import type { ITeamFeatureRepository } from "@calcom/features/flags/repositories/PrismaTeamFeatureRepository";
|
|
import type { IUserFeatureRepository } from "@calcom/features/flags/repositories/PrismaUserFeatureRepository";
|
|
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
|
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
|
import type { TeamFeaturesDto } from "@calcom/lib/dto/TeamFeaturesDto";
|
|
import type { UserFeaturesDto } from "@calcom/lib/dto/UserFeaturesDto";
|
|
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";
|
|
|
|
function teamFeatureToState(teamFeature: TeamFeaturesDto | undefined): FeatureState {
|
|
if (!teamFeature) return "inherit";
|
|
if (teamFeature.enabled) return "enabled";
|
|
return "disabled";
|
|
}
|
|
|
|
function userFeatureToState(userFeature: UserFeaturesDto | undefined): FeatureState {
|
|
if (!userFeature) return "inherit";
|
|
if (userFeature.enabled) return "enabled";
|
|
return "disabled";
|
|
}
|
|
|
|
interface IFeatureOptInServiceDeps {
|
|
featureRepo: IFeatureRepository;
|
|
teamFeatureRepo: ITeamFeatureRepository;
|
|
userFeatureRepo: IUserFeatureRepository;
|
|
}
|
|
|
|
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, TeamFeaturesDto>): FeatureState {
|
|
if (orgId !== null) {
|
|
return teamFeatureToState(teamStatesById[orgId]);
|
|
}
|
|
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: Partial<Record<FeatureId, Record<number, TeamFeaturesDto>>>,
|
|
slug: FeatureId
|
|
): FeatureState {
|
|
if (parentOrgId) {
|
|
return teamFeatureToState(teamStates[slug]?.[parentOrgId]);
|
|
}
|
|
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 deps: IFeatureOptInServiceDeps) {}
|
|
|
|
/**
|
|
* 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.deps.featureRepo.findAll(),
|
|
this.deps.teamFeatureRepo.findByTeamIdsAndFeatureIds(allTeamIds, featureIds),
|
|
this.deps.userFeatureRepo.findByUserIdAndFeatureIds(userId, featureIds),
|
|
this.deps.userFeatureRepo.findAutoOptInByUserId(userId),
|
|
this.deps.teamFeatureRepo.findAutoOptInByTeamIds(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: Partial<Record<FeatureId, Record<number, TeamFeaturesDto>>>,
|
|
userStates: Partial<Record<FeatureId, UserFeaturesDto>>,
|
|
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) => teamFeatureToState(teamStatesById[teamId]));
|
|
const userState = userFeatureToState(userStates[featureId]);
|
|
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,
|
|
};
|
|
}
|
|
|
|
async resolveFeatureStates(input: {
|
|
userId: number;
|
|
featureIds: FeatureId[];
|
|
}): Promise<Record<string, ResolvedFeatureState>> {
|
|
const { userId, featureIds } = input;
|
|
const { orgId, teamIds } = await this.getUserOrgAndTeamIds(userId);
|
|
|
|
return this.resolveFeatureStatesAcrossTeams({
|
|
userId,
|
|
orgId,
|
|
teamIds,
|
|
featureIds,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* List all opt-in features with their states for a user across teams.
|
|
* Only returns features that are in the allowlist, globally enabled, scoped to "user",
|
|
* and configured to be displayed in settings.
|
|
*/
|
|
async listFeaturesForUser(input: { userId: number }): Promise<ListFeaturesForUserResult[]> {
|
|
const { userId } = input;
|
|
const userScopedFeatures = getOptInFeaturesForScope("user", "settings");
|
|
const featureIds = userScopedFeatures.map((config) => config.slug);
|
|
|
|
const resolvedStates = await this.resolveFeatureStates({
|
|
userId,
|
|
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, scoped to the specified scope,
|
|
* and configured to be displayed in settings.
|
|
* 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, "settings");
|
|
|
|
const [allFeatures, teamStates] = await Promise.all([
|
|
this.deps.featureRepo.findAll(),
|
|
this.deps.teamFeatureRepo.findByTeamIdsAndFeatureIds(
|
|
teamIdsToQuery,
|
|
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 = teamFeatureToState(teamStates[config.slug]?.[teamId]);
|
|
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.deps.userFeatureRepo.delete(userId, featureId);
|
|
} else {
|
|
const { assignedBy } = input;
|
|
await this.deps.userFeatureRepo.upsert(userId, featureId, state === "enabled", `user-${assignedBy}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set team's feature state.
|
|
* 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.deps.teamFeatureRepo.delete(teamId, featureId);
|
|
} else {
|
|
const { assignedBy } = input;
|
|
await this.deps.teamFeatureRepo.upsert(teamId, featureId, state === "enabled", `user-${assignedBy}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export type { FeatureOptInEligibilityStatus };
|
|
export type { IFeatureOptInServiceDeps };
|
|
export type { FeatureOptInEligibilityResult, UserRoleContext } from "./IFeatureOptInService";
|