* feat(flags): add @Memoize and @Unmemoize decorators for declarative caching - Add @Memoize decorator for caching method results with Zod validation - Add @Unmemoize decorator for cache invalidation on mutations - Create UserFeatureRepository using decorators as example implementation - Add comprehensive tests with 80%+ coverage (19 tests) - Add DI module and tokens for UserFeatureRepository - Enable experimentalDecorators in packages/features/tsconfig.json Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor(cache): use lazy Redis lookup in decorators - Decorators now access Redis via getRedisService() instead of this.redis - UserFeatureRepository no longer has redis property or direct redis calls - findByUserIdAndFeatureIds now uses decorated findByUserIdAndFeatureId - DI module simplified to only pass prisma dependency - Tests updated to call setRedisService() in beforeEach This ensures the repository only knows about Prisma, with all caching handled transparently by the decorators. Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * feat(flags): add FeatureRepository and TeamFeatureRepository with decorator-based caching - Add FeatureRepository with @Memoize decorators for findAll, findBySlug, getFeatureFlagMap - Add TeamFeatureRepository with @Memoize/@Unmemoize decorators for all CRUD operations - Add comprehensive Zod schemas for Feature, TeamFeatures, and AppFlags validation - Add DI modules and tokens for both repositories - Add comprehensive test coverage (33 tests) for both repositories - Maintain backward compatibility with existing FEATURES_REPOSITORY tokens Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor(cache): use DI container pattern for Redis service - Create packages/features/di/containers/Redis.ts with getRedisService() - Update Memoize and Unmemoize decorators to import from DI container - Remove setRedisService() and IRedisService from types.ts exports - Update all tests to mock the container's getRedisService - Fix import ordering issues from biome Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor(cache): remove barrel import and add root-level cache export - Remove packages/features/cache/decorators/index.ts barrel file - Add packages/features/cache/index.ts as root-level export for cache feature - Update repository imports to use direct imports from source files - Follows the pattern: avoid barrel imports at nested levels, keep at feature root Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor(flags): minimize repository methods and address PR feedback - Remove unnecessary methods from FeatureRepository, TeamFeatureRepository, and UserFeatureRepository - Keep only methods needed by FeatureOptInService - Update imports to use @calcom/features/cache public API instead of relative paths - Handle redis.del() errors gracefully in Unmemoize decorator - Update tests to match simplified repositories Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor(cache): use Promise.allSettled for cache invalidation Cleaner approach than Promise.all with individual .catch() handlers Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * feat(flags): add setAutoOptIn methods and replace featuresRepository usage in _router.ts - Add setAutoOptIn method to TeamFeatureRepository with @Unmemoize decorator - Add setAutoOptIn method to UserFeatureRepository with @Unmemoize decorator - Replace featuresRepository usage in featureOptIn/_router.ts with new repositories - TeamFeatureRepository.setAutoOptIn unmemoizes KEY.autoOptInByTeamId(teamId) - UserFeatureRepository.setAutoOptIn unmemoizes KEY.autoOptInByUserId(userId) Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor(flags): use DI containers for TeamFeatureRepository and UserFeatureRepository - Create TeamFeatureRepository.ts container with getTeamFeatureRepository() - Create UserFeatureRepository.ts container with getUserFeatureRepository() - Update _router.ts to use DI containers instead of direct instantiation Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor(feature-opt-in): use DI containers for FeatureOptInService dependencies - Update FeatureOptInService to use IFeatureOptInServiceDeps with 3 repositories - Add helper functions teamFeatureToState() and userFeatureToState() - Update DI module to use depsMap pattern with featureRepo, teamFeatureRepo, userFeatureRepo - Create FeatureRepository container file - Replace all FeaturesRepository method calls with new repository methods Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * test(feature-opt-in): update FeatureOptInService tests for new IFeatureOptInServiceDeps interface - Update mock structure to use featureRepo, teamFeatureRepo, userFeatureRepo - Add helper functions createMockTeamFeature and createMockUserFeature - Update all test cases to use new repository method names - Add explicit types to satisfy biome lint rules Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor(flags): use DTOs at repository boundaries to prevent Prisma type leakage - Create FeatureDto, TeamFeaturesDto, UserFeaturesDto in packages/lib/dto/ - Update repository interfaces to return DTOs instead of Prisma types - Add toDto() transformation methods in repository implementations - Update FeatureOptInService to use DTOs instead of Prisma types - Follow data-dto-boundaries.md guidelines for architectural boundaries Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix(flags): update TeamFeatureRepository tests to match DTO structure Remove assignedAt from test mock data since TeamFeaturesDto only includes: teamId, featureId, enabled, assignedBy, and updatedAt Co-Authored-By: unknown <> * fix(cache): make Redis failures non-blocking in @Memoize decorator Wrap Redis get and set operations in try-catch blocks to ensure Redis failures don't break the application flow. If cache read fails, proceed to fetch from source. If cache write fails, silently ignore and return the result. Co-Authored-By: unknown <> * fix(cache): add logging for Redis failures and remove barrel import - Add warning logs for Redis failures in @Memoize and @Unmemoize decorators - Remove packages/lib/dto/index.ts barrel import file - Update all imports to use direct file paths instead of barrel imports Co-Authored-By: unknown <> * fix(cache): sanitize log messages to avoid exposing sensitive data - Remove cacheKey from log messages (may contain PII like user/team IDs) - Log only error.message instead of raw error objects - Addresses Cubic AI feedback (confidence 9/10) Co-Authored-By: unknown <> * sanitize log --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Volnei Munhoz <volnei@cal.com>
513 lines
16 KiB
TypeScript
513 lines
16 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/FeatureRepository";
|
|
import type { ITeamFeatureRepository } from "@calcom/features/flags/repositories/TeamFeatureRepository";
|
|
import type { IUserFeatureRepository } from "@calcom/features/flags/repositories/UserFeatureRepository";
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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.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";
|