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>
This commit is contained in:
Eunjae Lee
2026-01-06 16:55:53 +01:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 750408ec21
commit f4248bf20d
35 changed files with 3564 additions and 450 deletions
@@ -112,7 +112,11 @@ describe("Organizations Roles Endpoints", () => {
});
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledOrganization.id, "pbac", "enabled");
await featuresRepositoryFixture.setTeamFeatureState({
teamId: pbacEnabledOrganization.id,
featureId: "pbac",
state: "enabled",
});
// Create memberships
await membershipRepositoryFixture.create({
@@ -58,7 +58,11 @@ describe("Organizations Roles Permissions Endpoints", () => {
});
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledOrganization.id, "pbac", "enabled");
await featuresRepositoryFixture.setTeamFeatureState({
teamId: pbacEnabledOrganization.id,
featureId: "pbac",
state: "enabled",
});
// Create user + membership in org
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
@@ -126,7 +126,11 @@ describe("Organizations Roles Endpoints", () => {
});
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledTeam.id, "pbac", "enabled");
await featuresRepositoryFixture.setTeamFeatureState({
teamId: pbacEnabledTeam.id,
featureId: "pbac",
state: "enabled",
});
// Create memberships
await membershipRepositoryFixture.create({
@@ -67,7 +67,11 @@ describe("Organizations Teams Roles Permissions Endpoints", () => {
});
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledTeam.id, "pbac", "enabled");
await featuresRepositoryFixture.setTeamFeatureState({
teamId: pbacEnabledTeam.id,
featureId: "pbac",
state: "enabled",
});
// Create user + membership in org
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
@@ -2,15 +2,19 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import type { FeatureId } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import type { Prisma } from "@calcom/prisma/client";
export class FeaturesRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
private featuresRepository: FeaturesRepository;
constructor(module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
this.featuresRepository = new FeaturesRepository(this.prismaWriteClient);
}
async create(data: Prisma.FeatureCreateInput) {
// note(Lauris): upserting because this create function is called in multiple tests in parallel and otherwise would lead to unique
@@ -22,52 +26,32 @@ export class FeaturesRepositoryFixture {
});
}
async createTeamFeature(data: Prisma.TeamFeaturesCreateInput) {
return await this.prismaWriteClient.teamFeatures.create({
data,
});
}
async setTeamFeatureState(
teamId: number,
featureId: string,
state: "enabled" | "disabled" | "inherit",
assignedBy = "test"
input:
| { teamId: number; featureId: string; state: "enabled" | "disabled"; assignedBy?: string }
| { teamId: number; featureId: string; state: "inherit" }
) {
if (state === "enabled" || state === "disabled") {
await this.prismaWriteClient.teamFeatures.upsert({
where: {
teamId_featureId: {
teamId,
featureId,
},
},
create: {
teamId,
featureId,
assignedBy,
enabled: state === "enabled",
},
update: {
enabled: state === "enabled",
},
if (input.state === "inherit") {
await this.featuresRepository.setTeamFeatureState({
teamId: input.teamId,
featureId: input.featureId as FeatureId,
state: input.state,
});
} else if (state === "inherit") {
await this.prismaWriteClient.teamFeatures.deleteMany({
where: {
teamId,
featureId,
},
} else {
await this.featuresRepository.setTeamFeatureState({
teamId: input.teamId,
featureId: input.featureId as FeatureId,
state: input.state,
assignedBy: input.assignedBy ?? "test",
});
}
}
async disableFeatureForTeam(teamId: number, featureSlug: string) {
return await this.prismaWriteClient.teamFeatures.deleteMany({
where: {
teamId,
featureId: featureSlug,
},
await this.featuresRepository.setTeamFeatureState({
teamId,
featureId: featureSlug as FeatureId,
state: "inherit",
});
}
@@ -78,11 +62,10 @@ export class FeaturesRepositoryFixture {
}
async deleteTeamFeature(teamId: number, featureSlug: string) {
return await this.prismaWriteClient.teamFeatures.deleteMany({
where: {
teamId,
featureId: featureSlug,
},
await this.featuresRepository.setTeamFeatureState({
teamId,
featureId: featureSlug as FeatureId,
state: "inherit",
});
}
}
+24 -19
View File
@@ -6,7 +6,8 @@ import { v4 } from "uuid";
import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes";
import stripe from "@calcom/features/ee/payments/server/stripe";
import type { AppFlags } from "@calcom/features/flags/config";
import type { AppFlags, FeatureId } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -253,15 +254,17 @@ const createTeamAndAddUser = async (
// Enable feature flags for the team if specified
if (teamFeatureFlags && teamFeatureFlags.length > 0) {
await prisma.teamFeatures.createMany({
data: teamFeatureFlags.map((featureFlag) => ({
teamId: team.id,
featureId: featureFlag,
assignedBy: "e2e-fixture",
assignedAt: new Date(),
enabled: true,
})),
});
const featuresRepository = new FeaturesRepository(prisma);
await Promise.all(
teamFeatureFlags.map((featureFlag) =>
featuresRepository.setTeamFeatureState({
teamId: team.id,
featureId: featureFlag as FeatureId,
state: "enabled",
assignedBy: "e2e-fixture",
})
)
);
}
return team;
@@ -423,15 +426,17 @@ export const createUsersFixture = (
// Default to DEFAULT_USER_FEATURE_FLAGS if not specified
const userFeatureFlags = opts?.userFeatureFlags ?? DEFAULT_USER_FEATURE_FLAGS;
if (userFeatureFlags.length > 0) {
await prisma.userFeatures.createMany({
data: userFeatureFlags.map((featureFlag) => ({
userId: user.id,
featureId: featureFlag,
assignedBy: "e2e-fixture",
assignedAt: new Date(),
enabled: true,
})),
});
const featuresRepository = new FeaturesRepository(prisma);
await Promise.all(
userFeatureFlags.map((featureFlag) =>
featuresRepository.setUserFeatureState({
userId: user.id,
featureId: featureFlag as FeatureId,
state: "enabled",
assignedBy: "e2e-fixture",
})
)
);
}
if (scenario.hasTeam) {
+8 -8
View File
@@ -1,3 +1,5 @@
import type { FeatureId } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
import { prisma } from "@calcom/prisma";
@@ -18,13 +20,11 @@ export const createAllPermissionsArray = () => {
};
export const enablePBACForTeam = async (teamId: number) => {
await prisma.teamFeatures.create({
data: {
featureId: "pbac",
teamId: teamId,
assignedBy: "e2e",
assignedAt: new Date(),
enabled: true,
},
const featuresRepository = new FeaturesRepository(prisma);
await featuresRepository.setTeamFeatureState({
teamId,
featureId: "pbac" as FeatureId,
state: "enabled",
assignedBy: "e2e",
});
};
@@ -1,3 +1,5 @@
import { organizationRepositoryMock } from "@calcom/features/ee/organizations/__mocks__/organizationMock";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe, it, expect, beforeEach, vi } from "vitest";
@@ -6,7 +8,6 @@ import { metadata as googleCalendarMetadata } from "@calcom/app-store/googlecale
import { metadata as googleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata";
import type { ServiceAccountKey } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
import { organizationRepositoryMock } from "@calcom/features/ee/organizations/__mocks__/organizationMock";
import { SMSLockState, RRTimestampBasis } from "@calcom/prisma/enums";
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
@@ -105,6 +106,7 @@ const mockOrganization = {
hideTeamProfileLink: false,
rrResetInterval: null,
rrTimestampBasis: RRTimestampBasis.CREATED_AT,
autoOptInFeatures: false,
};
// Credential Builders
@@ -1,5 +1,7 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import type { FeatureId } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { prisma } from "@calcom/prisma";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
@@ -8,6 +10,8 @@ import type { BookingAuditViewerService } from "./BookingAuditViewerService";
import { makeUserActor } from "../makeActor";
import { getBookingAuditTaskConsumer } from "../../di/BookingAuditTaskConsumer.container";
import { getBookingAuditViewerService } from "../../di/BookingAuditViewerService.container";
import type { BookingAuditTaskConsumer } from "./BookingAuditTaskConsumer";
import type { BookingAuditViewerService } from "./BookingAuditViewerService";
const generateUniqueId = () => {
const timestamp = Date.now();
@@ -15,7 +19,11 @@ const generateUniqueId = () => {
return `${timestamp}-${randomSuffix}`;
};
const createTestUser = async (overrides?: { email?: string; username?: string; name?: string }) => {
const createTestUser = async (overrides?: {
email?: string;
username?: string;
name?: string;
}) => {
const uniqueId = generateUniqueId();
return prisma.user.create({
data: {
@@ -26,7 +34,10 @@ const createTestUser = async (overrides?: { email?: string; username?: string; n
});
};
const createTestOrganization = async (overrides?: { name?: string; slug?: string }) => {
const createTestOrganization = async (overrides?: {
name?: string;
slug?: string;
}) => {
const uniqueId = generateUniqueId();
return prisma.team.create({
data: {
@@ -81,7 +92,8 @@ const createTestBooking = async (
) => {
const uniqueId = generateUniqueId();
const startTime = overrides?.startTime || new Date();
const endTime = overrides?.endTime || new Date(startTime.getTime() + 60 * 60 * 1000);
const endTime =
overrides?.endTime || new Date(startTime.getTime() + 60 * 60 * 1000);
return prisma.booking.create({
data: {
@@ -99,7 +111,10 @@ const createTestBooking = async (
});
};
const enableFeatureForOrganization = async (organizationId: number, featureSlug: string) => {
const enableFeatureForOrganization = async (
organizationId: number,
featureSlug: string
) => {
await prisma.feature.upsert({
where: { slug: featureSlug },
create: {
@@ -112,22 +127,12 @@ const enableFeatureForOrganization = async (organizationId: number, featureSlug:
},
});
await prisma.teamFeatures.upsert({
where: {
teamId_featureId: {
teamId: organizationId,
featureId: featureSlug,
},
},
create: {
teamId: organizationId,
featureId: featureSlug,
assignedBy: "test-system",
enabled: true,
},
update: {
enabled: true,
},
const featuresRepository = new FeaturesRepository(prisma);
await featuresRepository.setTeamFeatureState({
teamId: organizationId,
featureId: featureSlug as FeatureId,
state: "enabled",
assignedBy: "test-system",
});
};
@@ -177,11 +182,11 @@ const cleanupTestData = async (testData: {
if (testData.organizationId) {
if (testData.featureSlug) {
await prisma.teamFeatures.deleteMany({
where: {
teamId: testData.organizationId,
featureId: testData.featureSlug,
},
const featuresRepository = new FeaturesRepository(prisma);
await featuresRepository.setTeamFeatureState({
teamId: testData.organizationId,
featureId: testData.featureSlug as FeatureId,
state: "inherit",
});
}
await prisma.membership.deleteMany({
@@ -208,7 +213,12 @@ describe("Booking Audit Integration", () => {
attendee: { id: number; email: string };
organization: { id: number };
eventType: { id: number };
booking: { uid: string; startTime: Date; endTime: Date; status: BookingStatus };
booking: {
uid: string;
startTime: Date;
endTime: Date;
status: BookingStatus;
};
};
beforeEach(async () => {
@@ -255,7 +265,9 @@ describe("Booking Audit Integration", () => {
attendeeEmails: testData.attendee?.email ? [testData.attendee.email] : [],
eventTypeId: testData.eventType?.id,
organizationId: testData.organization?.id,
userIds: [testData.owner?.id, testData.attendee?.id].filter((id): id is number => id !== undefined),
userIds: [testData.owner?.id, testData.attendee?.id].filter(
(id): id is number => id !== undefined
),
featureSlug: "booking-audit",
});
});
@@ -298,11 +310,15 @@ describe("Booking Audit Integration", () => {
expect(displayData).toBeDefined();
expect(displayData.startTime).toBeDefined();
expect(typeof displayData.startTime).toBe("string");
expect(displayData.startTime).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
expect(displayData.startTime).toMatch(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/
);
expect(displayData.endTime).toBeDefined();
expect(typeof displayData.endTime).toBe("string");
expect(displayData.endTime).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
expect(displayData.endTime).toMatch(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/
);
expect(displayData.status).toBe(testData.booking.status);
});
@@ -397,13 +413,14 @@ describe("Booking Audit Integration", () => {
timestamp: Date.now(),
});
const ownerResult = await bookingAuditViewerService.getAuditLogsForBooking({
bookingUid: testData.booking.uid,
userId: testData.owner.id,
userEmail: testData.owner.email,
userTimeZone: "UTC",
organizationId: testData.organization.id,
});
const ownerResult =
await bookingAuditViewerService.getAuditLogsForBooking({
bookingUid: testData.booking.uid,
userId: testData.owner.id,
userEmail: testData.owner.email,
userTimeZone: "UTC",
organizationId: testData.organization.id,
});
expect(ownerResult.auditLogs).toHaveLength(1);
const unauthorizedUserId = 999999;
@@ -423,25 +440,33 @@ describe("Booking Audit Integration", () => {
describe("when multiple bookings are created in bulk", () => {
it("should create audit records for all bookings with same operation ID", async () => {
const booking2 = await createTestBooking(testData.owner.id, testData.eventType.id, {
attendees: [
{
email: testData.attendee.email,
name: "Test Attendee",
timeZone: "UTC",
},
],
});
const booking2 = await createTestBooking(
testData.owner.id,
testData.eventType.id,
{
attendees: [
{
email: testData.attendee.email,
name: "Test Attendee",
timeZone: "UTC",
},
],
}
);
const booking3 = await createTestBooking(testData.owner.id, testData.eventType.id, {
attendees: [
{
email: testData.attendee.email,
name: "Test Attendee",
timeZone: "UTC",
},
],
});
const booking3 = await createTestBooking(
testData.owner.id,
testData.eventType.id,
{
attendees: [
{
email: testData.attendee.email,
name: "Test Attendee",
timeZone: "UTC",
},
],
}
);
const actor = makeUserActor(testData.owner.uuid);
const operationId = `bulk-op-${Date.now()}`;
@@ -532,4 +557,3 @@ describe("Booking Audit Integration", () => {
});
});
});
@@ -0,0 +1,35 @@
import type { FeatureId } from "@calcom/features/flags/config";
export interface OptInFeatureConfig {
slug: FeatureId;
titleI18nKey: string;
descriptionI18nKey: string;
}
/**
* Features that appear in opt-in settings.
* Add new features here to make them available for user/team opt-in.
*/
export const OPT_IN_FEATURES: OptInFeatureConfig[] = [
// Example - to be populated with actual features
// {
// slug: "bookings-v3",
// titleI18nKey: "bookings_v3_title",
// descriptionI18nKey: "bookings_v3_description",
// },
];
/**
* Get the configuration for a specific opt-in feature by slug.
*/
export function getOptInFeatureConfig(slug: string): OptInFeatureConfig | undefined {
return OPT_IN_FEATURES.find((f) => f.slug === slug);
}
/**
* Check if a feature slug is in the opt-in allowlist.
* Acts as a type guard, narrowing the slug to FeatureId when true.
*/
export function isOptInFeature(slug: string): slug is FeatureId {
return OPT_IN_FEATURES.some((f) => f.slug === slug);
}
@@ -0,0 +1,188 @@
import { describe, it, expect } from "vitest";
import { applyAutoOptIn } from "./applyAutoOptIn";
describe("applyAutoOptIn", () => {
describe("org state transformation", () => {
it("transforms org state from inherit to enabled when orgAutoOptIn is true", () => {
const result = applyAutoOptIn({
orgState: "inherit",
teamStates: [],
userState: "inherit",
orgAutoOptIn: true,
teamAutoOptIns: [],
userAutoOptIn: false,
});
expect(result.effectiveOrgState).toBe("enabled");
});
it("keeps org state as inherit when orgAutoOptIn is false", () => {
const result = applyAutoOptIn({
orgState: "inherit",
teamStates: [],
userState: "inherit",
orgAutoOptIn: false,
teamAutoOptIns: [],
userAutoOptIn: false,
});
expect(result.effectiveOrgState).toBe("inherit");
});
it("does not transform org state when it is explicitly enabled", () => {
const result = applyAutoOptIn({
orgState: "enabled",
teamStates: [],
userState: "inherit",
orgAutoOptIn: true,
teamAutoOptIns: [],
userAutoOptIn: false,
});
expect(result.effectiveOrgState).toBe("enabled");
});
it("does not transform org state when it is explicitly disabled", () => {
const result = applyAutoOptIn({
orgState: "disabled",
teamStates: [],
userState: "inherit",
orgAutoOptIn: true,
teamAutoOptIns: [],
userAutoOptIn: false,
});
expect(result.effectiveOrgState).toBe("disabled");
});
});
describe("team states transformation", () => {
it("transforms team states from inherit to enabled when corresponding autoOptIn is true", () => {
const result = applyAutoOptIn({
orgState: "inherit",
teamStates: ["inherit", "inherit", "inherit"],
userState: "inherit",
orgAutoOptIn: false,
teamAutoOptIns: [true, false, true],
userAutoOptIn: false,
});
expect(result.effectiveTeamStates).toEqual(["enabled", "inherit", "enabled"]);
});
it("does not transform team states when they are explicitly set", () => {
const result = applyAutoOptIn({
orgState: "inherit",
teamStates: ["enabled", "disabled", "inherit"],
userState: "inherit",
orgAutoOptIn: false,
teamAutoOptIns: [true, true, false],
userAutoOptIn: false,
});
expect(result.effectiveTeamStates).toEqual(["enabled", "disabled", "inherit"]);
});
});
describe("user state transformation", () => {
it("transforms user state from inherit to enabled when userAutoOptIn is true", () => {
const result = applyAutoOptIn({
orgState: "inherit",
teamStates: [],
userState: "inherit",
orgAutoOptIn: false,
teamAutoOptIns: [],
userAutoOptIn: true,
});
expect(result.effectiveUserState).toBe("enabled");
});
it("keeps user state as inherit when userAutoOptIn is false", () => {
const result = applyAutoOptIn({
orgState: "inherit",
teamStates: [],
userState: "inherit",
orgAutoOptIn: false,
teamAutoOptIns: [],
userAutoOptIn: false,
});
expect(result.effectiveUserState).toBe("inherit");
});
it("does not transform user state when it is explicitly enabled", () => {
const result = applyAutoOptIn({
orgState: "inherit",
teamStates: [],
userState: "enabled",
orgAutoOptIn: false,
teamAutoOptIns: [],
userAutoOptIn: true,
});
expect(result.effectiveUserState).toBe("enabled");
});
it("does not transform user state when it is explicitly disabled", () => {
const result = applyAutoOptIn({
orgState: "inherit",
teamStates: [],
userState: "disabled",
orgAutoOptIn: false,
teamAutoOptIns: [],
userAutoOptIn: true,
});
expect(result.effectiveUserState).toBe("disabled");
});
});
describe("combined transformations", () => {
it("transforms all levels independently when autoOptIn is true", () => {
const result = applyAutoOptIn({
orgState: "inherit",
teamStates: ["inherit", "inherit"],
userState: "inherit",
orgAutoOptIn: true,
teamAutoOptIns: [true, true],
userAutoOptIn: true,
});
expect(result.effectiveOrgState).toBe("enabled");
expect(result.effectiveTeamStates).toEqual(["enabled", "enabled"]);
expect(result.effectiveUserState).toBe("enabled");
});
it("only transforms levels with autoOptIn enabled", () => {
const result = applyAutoOptIn({
orgState: "inherit",
teamStates: ["inherit", "inherit"],
userState: "inherit",
orgAutoOptIn: false,
teamAutoOptIns: [true, false],
userAutoOptIn: true,
});
expect(result.effectiveOrgState).toBe("inherit");
expect(result.effectiveTeamStates).toEqual(["enabled", "inherit"]);
expect(result.effectiveUserState).toBe("enabled");
});
it("respects explicit states even when autoOptIn is true", () => {
const result = applyAutoOptIn({
orgState: "disabled",
teamStates: ["enabled", "disabled"],
userState: "enabled",
orgAutoOptIn: true,
teamAutoOptIns: [true, true],
userAutoOptIn: true,
});
expect(result.effectiveOrgState).toBe("disabled");
expect(result.effectiveTeamStates).toEqual(["enabled", "disabled"]);
expect(result.effectiveUserState).toBe("enabled");
});
});
});
@@ -0,0 +1,42 @@
import type { FeatureState } from "@calcom/features/flags/config";
/**
* Applies auto-opt-in transformation to feature states.
*
* If a level has autoOptIn enabled AND the state is "inherit", the state is transformed to "enabled".
* This allows entities to automatically opt into new features without explicit configuration.
*/
export function applyAutoOptIn({
orgState,
teamStates,
userState,
orgAutoOptIn,
teamAutoOptIns,
userAutoOptIn,
}: {
orgState: FeatureState;
teamStates: FeatureState[];
userState: FeatureState;
orgAutoOptIn: boolean;
teamAutoOptIns: boolean[];
userAutoOptIn: boolean;
}): {
effectiveOrgState: FeatureState;
effectiveTeamStates: FeatureState[];
effectiveUserState: FeatureState;
} {
const effectiveOrgState: FeatureState = orgState === "inherit" && orgAutoOptIn ? "enabled" : orgState;
const effectiveTeamStates: FeatureState[] = teamStates.map((state, index) => {
const autoOptIn = teamAutoOptIns[index];
return state === "inherit" && autoOptIn ? "enabled" : state;
});
const effectiveUserState: FeatureState = userState === "inherit" && userAutoOptIn ? "enabled" : userState;
return {
effectiveOrgState,
effectiveTeamStates,
effectiveUserState,
};
}
@@ -0,0 +1,352 @@
import { describe, it, expect } from "vitest";
import { computeEffectiveStateAcrossTeams } from "./computeEffectiveState";
describe("computeEffectiveStateAcrossTeams", () => {
describe("when global is disabled", () => {
it("returns false regardless of other states", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: false,
orgState: "enabled",
teamStates: ["enabled"],
userState: "enabled",
})
).toBe(false);
});
});
describe("when org is disabled", () => {
it("returns false regardless of team and user state", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "disabled",
teamStates: ["enabled"],
userState: "enabled",
})
).toBe(false);
});
});
describe("when org is enabled", () => {
describe("when all teams are disabled", () => {
it("returns false regardless of user state", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["disabled", "disabled"],
userState: "enabled",
})
).toBe(false);
});
});
describe("when at least one team is enabled", () => {
it("returns true when user is enabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["enabled", "disabled"],
userState: "enabled",
})
).toBe(true);
});
it("returns false when user is disabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["enabled", "disabled"],
userState: "disabled",
})
).toBe(false);
});
it("returns true when user inherits", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["enabled", "disabled"],
userState: "inherit",
})
).toBe(true);
});
});
describe("when teams inherit from enabled org", () => {
it("returns true when user is enabled or inherits", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["inherit"],
userState: "enabled",
})
).toBe(true);
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["inherit"],
userState: "inherit",
})
).toBe(true);
});
});
});
describe("when org inherits (or no org)", () => {
describe("when all teams are disabled", () => {
it("returns false regardless of user state", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["disabled"],
userState: "enabled",
})
).toBe(false);
});
});
describe("when at least one team is enabled", () => {
it("returns true when user is enabled or inherits", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled", "disabled"],
userState: "enabled",
})
).toBe(true);
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled"],
userState: "inherit",
})
).toBe(true);
});
it("returns false when user is disabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled"],
userState: "disabled",
})
).toBe(false);
});
});
describe("when teams only inherit (no org enabled)", () => {
it("returns true when user explicitly opts in", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["inherit"],
userState: "enabled",
})
).toBe(true);
});
it("returns false when user inherits because no explicit enablement above", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["inherit"],
userState: "inherit",
})
).toBe(false);
});
});
});
describe("when user has no teams", () => {
it("returns true when org is enabled and user is enabled/inherits", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: [],
userState: "enabled",
})
).toBe(true);
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: [],
userState: "inherit",
})
).toBe(true);
});
it("returns false when org inherits and user has no explicit enablement", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: [],
userState: "inherit",
})
).toBe(false); // No explicit enablement in chain, feature should be disabled
});
it("returns true when org inherits but user explicitly enables", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: [],
userState: "enabled",
})
).toBe(true); // User explicit enablement is sufficient
});
});
describe("user opt-in behavior", () => {
it("allows user to opt-in regardless of org/team inheritance state", () => {
// User can opt-in even when org and all teams are just inheriting
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["inherit", "inherit"],
userState: "enabled",
})
).toBe(true);
});
it("blocks user opt-in when all teams have explicitly disabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["disabled", "disabled"],
userState: "enabled",
})
).toBe(false);
});
it("blocks user opt-in when org has explicitly disabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "disabled",
teamStates: ["inherit"],
userState: "enabled",
})
).toBe(false);
});
});
describe("truth table from design doc", () => {
it("org disabled, any teams, any user → false", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "disabled",
teamStates: ["enabled"],
userState: "enabled",
})
).toBe(false);
});
it("org enabled, all teams disabled, any user → false", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["disabled", "disabled"],
userState: "enabled",
})
).toBe(false);
});
it("org enabled, at least one team enabled/inherit, user disabled → false", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["enabled"],
userState: "disabled",
})
).toBe(false);
});
it("org enabled, at least one team enabled/inherit, user enabled/inherit → true", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["enabled"],
userState: "enabled",
})
).toBe(true);
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["inherit"],
userState: "inherit",
})
).toBe(true);
});
it("org inherit/null, all teams disabled, any user → false", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["disabled"],
userState: "enabled",
})
).toBe(false);
});
it("org inherit/null, at least one team enabled, user disabled → false", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled"],
userState: "disabled",
})
).toBe(false);
});
it("org inherit/null, at least one team enabled, user enabled/inherit → true", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled"],
userState: "enabled",
})
).toBe(true);
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled"],
userState: "inherit",
})
).toBe(true);
});
});
});
@@ -0,0 +1,48 @@
import type { FeatureState } from "@calcom/features/flags/config";
/**
* Computes the effective enabled state based on global, org, teams, and user settings.
*
* The logic follows these rules:
* - Any level set to "disabled" blocks the feature (org blocks all, team blocks that team's users, user blocks self)
* - User can explicitly opt-in to enable the feature for themselves
* - "enabled" at org/team level provides enablement that users can inherit from
* - "inherit" passes through to the level above
*/
export function computeEffectiveStateAcrossTeams({
globalEnabled,
orgState,
teamStates,
userState,
}: {
/**
* Acts as a global kill switch. When false, the feature is disabled for everyone.
* When true, the feature is NOT necessarily enabled - enablement still depends on
* the org, team, and user state hierarchy.
*/
globalEnabled: boolean;
orgState: FeatureState;
teamStates: FeatureState[];
userState: FeatureState;
}): boolean {
// Derive all conditions upfront
const orgEnabled = orgState === "enabled";
const orgDisabled = orgState === "disabled";
const anyTeamEnabled = teamStates.some((s) => s === "enabled");
const allTeamsDisabled = teamStates.length > 0 && teamStates.every((s) => s === "disabled");
const userEnabled = userState === "enabled";
const userDisabled = userState === "disabled";
// Explicit enablement exists above user level
const hasExplicitEnablementAboveUser = orgEnabled || anyTeamEnabled;
// Define when feature is enabled (whitelist approach)
const isEnabled =
globalEnabled &&
!userDisabled &&
!orgDisabled &&
!allTeamsDisabled &&
(userEnabled || hasExplicitEnablementAboveUser); // User can opt-in directly, or inherit from org/team
return isEnabled;
}
@@ -0,0 +1,908 @@
import { afterEach, describe, expect, it } from "vitest";
import type { FeatureId } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { prisma } from "@calcom/prisma";
import { FeatureOptInService } from "./FeatureOptInService";
// Helper to generate unique identifiers per test
const uniqueId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
// Store cleanup functions to ensure they run even if tests fail
const cleanupFunctions: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const cleanup of cleanupFunctions) {
await cleanup();
}
cleanupFunctions.length = 0;
});
// Access private clearCache method through type assertion
const clearFeaturesCache = (repo: FeaturesRepository) => {
(repo as unknown as { clearCache: () => void }).clearCache();
};
interface TestEntities {
user: { id: number };
org: { id: number };
team: { id: number };
team2: { id: number };
featuresRepository: FeaturesRepository;
service: FeatureOptInService;
createdFeatures: string[];
setupFeature: (enabled?: boolean) => Promise<FeatureId>;
}
/**
* Creates isolated test entities (user, org, teams) and returns them along with a cleanup function.
* Each test gets its own entities, ensuring complete isolation even when tests run in parallel.
*/
async function setup(): Promise<TestEntities> {
const id = uniqueId();
const createdFeatures: string[] = [];
// Create test user
const user = await prisma.user.create({
data: {
email: `test-opt-in-${id}@example.com`,
username: `test-opt-in-${id}`,
},
});
// Create test org
const org = await prisma.team.create({
data: {
name: `Test OptIn Org ${id}`,
slug: `test-opt-in-org-${id}`,
isOrganization: true,
},
});
// Create test teams
const team = await prisma.team.create({
data: {
name: `Test OptIn Team ${id}`,
slug: `test-opt-in-team-${id}`,
parentId: org.id,
},
});
const team2 = await prisma.team.create({
data: {
name: `Test OptIn Team 2 ${id}`,
slug: `test-opt-in-team-2-${id}`,
parentId: org.id,
},
});
const featuresRepository = new FeaturesRepository(prisma);
const service = new FeatureOptInService(featuresRepository);
// Helper to create a feature for a test and track it for cleanup
const setupFeature = async (enabled = true): Promise<FeatureId> => {
const featureSlug = `test-opt-in-feature-${uniqueId()}` as FeatureId;
createdFeatures.push(featureSlug);
await prisma.feature.create({
data: {
slug: featureSlug,
enabled,
type: "EXPERIMENT",
},
});
// Clear cache after creating feature so getAllFeatures() returns fresh data
clearFeaturesCache(featuresRepository);
return featureSlug;
};
// Cleanup function - automatically registered with afterEach
const cleanup = async () => {
// Clean up in correct order to respect foreign key constraints
await prisma.userFeatures.deleteMany({
where: { userId: user.id },
});
await prisma.teamFeatures.deleteMany({
where: { teamId: { in: [team.id, team2.id, org.id] } },
});
await prisma.feature.deleteMany({
where: { slug: { in: createdFeatures } },
});
await prisma.membership.deleteMany({
where: { userId: user.id },
});
await prisma.team.deleteMany({
where: { id: { in: [team.id, team2.id] } },
});
await prisma.team.deleteMany({
where: { id: org.id },
});
await prisma.user.delete({
where: { id: user.id },
});
};
// Register cleanup to run automatically after each test
cleanupFunctions.push(cleanup);
return {
user,
org,
team,
team2,
featuresRepository,
service,
createdFeatures,
setupFeature,
};
}
describe("FeatureOptInService Integration Tests", () => {
describe("resolveFeatureStatesAcrossTeams", () => {
describe("global feature disabled", () => {
it("should return effectiveEnabled=false regardless of other states", async () => {
const { user, org, team, service, setupFeature } = await setup();
const testFeature = await setupFeature(false);
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: org.id,
teamIds: [team.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.effectiveEnabled).toBe(false);
});
});
describe("org disabled", () => {
it("should return effectiveEnabled=false regardless of team and user states", async () => {
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
const testFeature = await setupFeature(true);
// Org explicitly disables
await featuresRepository.setTeamFeatureState({
teamId: org.id,
featureId: testFeature,
state: "disabled",
assignedBy: "test",
});
// Team enables
await featuresRepository.setTeamFeatureState({
teamId: team.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
// User enables
await featuresRepository.setUserFeatureState({
userId: user.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: org.id,
teamIds: [team.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.orgState).toBe("disabled");
expect(status.effectiveEnabled).toBe(false);
});
});
describe("org enabled", () => {
it("should return effectiveEnabled=false when all teams are disabled", async () => {
const { user, org, team, team2, service, setupFeature, featuresRepository } = await setup();
const testFeature = await setupFeature(true);
// Org enables
await featuresRepository.setTeamFeatureState({
teamId: org.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
// Both teams disable
await Promise.all([
featuresRepository.setTeamFeatureState({
teamId: team.id,
featureId: testFeature,
state: "disabled",
assignedBy: "test",
}),
featuresRepository.setTeamFeatureState({
teamId: team2.id,
featureId: testFeature,
state: "disabled",
assignedBy: "test",
}),
]);
// User enables
await featuresRepository.setUserFeatureState({
userId: user.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: org.id,
teamIds: [team.id, team2.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.orgState).toBe("enabled");
expect(status.teamStates).toEqual(["disabled", "disabled"]);
expect(status.effectiveEnabled).toBe(false);
});
it("should return effectiveEnabled=true when at least one team is enabled and user inherits", async () => {
const { user, org, team, team2, service, setupFeature, featuresRepository } = await setup();
const testFeature = await setupFeature(true);
// Org enables
await featuresRepository.setTeamFeatureState({
teamId: org.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
// One team enables, one disables
await Promise.all([
featuresRepository.setTeamFeatureState({
teamId: team.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
}),
featuresRepository.setTeamFeatureState({
teamId: team2.id,
featureId: testFeature,
state: "disabled",
assignedBy: "test",
}),
]);
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: org.id,
teamIds: [team.id, team2.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.orgState).toBe("enabled");
expect(status.userState).toBe("inherit");
expect(status.effectiveEnabled).toBe(true);
});
it("should return effectiveEnabled=false when user explicitly disables", async () => {
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
const testFeature = await setupFeature(true);
// Org enables
await featuresRepository.setTeamFeatureState({
teamId: org.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
// Team enables
await featuresRepository.setTeamFeatureState({
teamId: team.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
// User disables
await featuresRepository.setUserFeatureState({
userId: user.id,
featureId: testFeature,
state: "disabled",
assignedBy: "test",
});
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: org.id,
teamIds: [team.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.userState).toBe("disabled");
expect(status.effectiveEnabled).toBe(false);
});
it("should return effectiveEnabled=true when teams inherit from enabled org", async () => {
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
const testFeature = await setupFeature(true);
// Org enables
await featuresRepository.setTeamFeatureState({
teamId: org.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
// Teams inherit (no rows)
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: org.id,
teamIds: [team.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.orgState).toBe("enabled");
expect(status.teamStates).toEqual(["inherit"]);
expect(status.effectiveEnabled).toBe(true);
});
});
describe("org inherits (or no org)", () => {
it("should return effectiveEnabled=false when all teams are disabled", async () => {
const { user, team, service, setupFeature, featuresRepository } = await setup();
const testFeature = await setupFeature(true);
// Team disables
await featuresRepository.setTeamFeatureState({
teamId: team.id,
featureId: testFeature,
state: "disabled",
assignedBy: "test",
});
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: null,
teamIds: [team.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.orgState).toBe("inherit");
expect(status.teamStates).toEqual(["disabled"]);
expect(status.effectiveEnabled).toBe(false);
});
it("should return effectiveEnabled=true when at least one team is enabled", async () => {
const { user, team, service, setupFeature, featuresRepository } = await setup();
const testFeature = await setupFeature(true);
// One team enables
await featuresRepository.setTeamFeatureState({
teamId: team.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: null,
teamIds: [team.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.teamStates).toEqual(["enabled"]);
expect(status.effectiveEnabled).toBe(true);
});
it("should return effectiveEnabled=false when teams only inherit (no explicit enablement)", async () => {
const { user, team, service, setupFeature } = await setup();
const testFeature = await setupFeature(true);
// No org, team inherits (no row)
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: null,
teamIds: [team.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.orgState).toBe("inherit");
expect(status.teamStates).toEqual(["inherit"]);
expect(status.effectiveEnabled).toBe(false);
});
});
describe("no teams", () => {
it("should return effectiveEnabled=true when org is enabled and user inherits", async () => {
const { user, org, service, setupFeature, featuresRepository } = await setup();
const testFeature = await setupFeature(true);
// Org enables
await featuresRepository.setTeamFeatureState({
teamId: org.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: org.id,
teamIds: [],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.orgState).toBe("enabled");
expect(status.teamStates).toEqual([]);
expect(status.effectiveEnabled).toBe(true);
});
});
});
describe("setUserFeatureState", () => {
it("should delete the row when state is 'inherit'", async () => {
const { user, service, setupFeature, featuresRepository } = await setup();
const testFeature = await setupFeature(true);
// First create a row
await featuresRepository.setUserFeatureState({
userId: user.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
// Set to inherit (should delete)
await service.setUserFeatureState({
userId: user.id,
featureId: testFeature,
state: "inherit",
});
const row = await prisma.userFeatures.findFirst({
where: {
userId: user.id,
featureId: testFeature,
},
});
expect(row).toBeNull();
});
it("should upsert with enabled=true when state is 'enabled'", async () => {
const { user, service, setupFeature } = await setup();
const testFeature = await setupFeature(true);
await service.setUserFeatureState({
userId: user.id,
featureId: testFeature,
state: "enabled",
assignedBy: user.id,
});
const row = await prisma.userFeatures.findFirst({
where: {
userId: user.id,
featureId: testFeature,
},
});
expect(row).not.toBeNull();
expect(row?.enabled).toBe(true);
});
it("should upsert with enabled=false when state is 'disabled'", async () => {
const { user, service, setupFeature } = await setup();
const testFeature = await setupFeature(true);
await service.setUserFeatureState({
userId: user.id,
featureId: testFeature,
state: "disabled",
assignedBy: user.id,
});
const row = await prisma.userFeatures.findFirst({
where: {
userId: user.id,
featureId: testFeature,
},
});
expect(row).not.toBeNull();
expect(row?.enabled).toBe(false);
});
});
describe("setTeamFeatureState", () => {
it("should delete the row when state is 'inherit'", async () => {
const { team, service, setupFeature, featuresRepository } = await setup();
const testFeature = await setupFeature(true);
// First create a row
await featuresRepository.setTeamFeatureState({
teamId: team.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
// Set to inherit (should delete)
await service.setTeamFeatureState({
teamId: team.id,
featureId: testFeature,
state: "inherit",
});
const row = await prisma.teamFeatures.findUnique({
where: {
teamId_featureId: {
teamId: team.id,
featureId: testFeature,
},
},
});
expect(row).toBeNull();
});
it("should upsert with enabled=true when state is 'enabled'", async () => {
const { user, team, service, setupFeature } = await setup();
const testFeature = await setupFeature(true);
await service.setTeamFeatureState({
teamId: team.id,
featureId: testFeature,
state: "enabled",
assignedBy: user.id,
});
const row = await prisma.teamFeatures.findUnique({
where: {
teamId_featureId: {
teamId: team.id,
featureId: testFeature,
},
},
});
expect(row).not.toBeNull();
expect(row?.enabled).toBe(true);
});
it("should upsert with enabled=false when state is 'disabled'", async () => {
const { user, team, service, setupFeature } = await setup();
const testFeature = await setupFeature(true);
await service.setTeamFeatureState({
teamId: team.id,
featureId: testFeature,
state: "disabled",
assignedBy: user.id,
});
const row = await prisma.teamFeatures.findUnique({
where: {
teamId_featureId: {
teamId: team.id,
featureId: testFeature,
},
},
});
expect(row).not.toBeNull();
expect(row?.enabled).toBe(false);
});
});
describe("auto-opt-in scenarios", () => {
describe("user autoOptInFeatures", () => {
it("should transform user state from 'inherit' to 'enabled' when user has autoOptInFeatures=true", async () => {
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
const testFeature = await setupFeature(true);
// Enable auto-opt-in for user
await prisma.user.update({
where: { id: user.id },
data: { autoOptInFeatures: true },
});
// Org enables (to allow team level)
await featuresRepository.setTeamFeatureState({
teamId: org.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
// Team enables (to allow user level)
await featuresRepository.setTeamFeatureState({
teamId: team.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
// User has no explicit state (inherit), but autoOptInFeatures=true should enable
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: org.id,
teamIds: [team.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.userState).toBe("inherit"); // Raw state is still inherit
expect(status.userAutoOptIn).toBe(true);
expect(status.effectiveEnabled).toBe(true); // But effective is true due to auto-opt-in
});
it("should NOT transform explicit 'disabled' state when user has autoOptInFeatures=true", async () => {
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
const testFeature = await setupFeature(true);
// Enable auto-opt-in for user
await prisma.user.update({
where: { id: user.id },
data: { autoOptInFeatures: true },
});
// Org enables
await featuresRepository.setTeamFeatureState({
teamId: org.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
// Team enables
await featuresRepository.setTeamFeatureState({
teamId: team.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
// User explicitly disables
await featuresRepository.setUserFeatureState({
userId: user.id,
featureId: testFeature,
state: "disabled",
assignedBy: "test",
});
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: org.id,
teamIds: [team.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.userState).toBe("disabled");
expect(status.userAutoOptIn).toBe(true);
expect(status.effectiveEnabled).toBe(false); // Explicit disabled takes precedence
});
});
describe("team autoOptInFeatures", () => {
it("should transform team state from 'inherit' to 'enabled' when team has autoOptInFeatures=true", async () => {
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
const testFeature = await setupFeature(true);
// Enable auto-opt-in for team
await prisma.team.update({
where: { id: team.id },
data: { autoOptInFeatures: true },
});
// Org enables (to allow team level)
await featuresRepository.setTeamFeatureState({
teamId: org.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
// Team has no explicit state (inherit), but autoOptInFeatures=true should enable
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: org.id,
teamIds: [team.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.teamStates).toEqual(["inherit"]); // Raw state is still inherit
expect(status.teamAutoOptIns).toEqual([true]);
expect(status.effectiveEnabled).toBe(true); // Effective is true due to team auto-opt-in
});
it("should NOT transform explicit 'disabled' state when team has autoOptInFeatures=true", async () => {
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
const testFeature = await setupFeature(true);
// Enable auto-opt-in for team
await prisma.team.update({
where: { id: team.id },
data: { autoOptInFeatures: true },
});
// Org enables
await featuresRepository.setTeamFeatureState({
teamId: org.id,
featureId: testFeature,
state: "enabled",
assignedBy: "test",
});
// Team explicitly disables
await featuresRepository.setTeamFeatureState({
teamId: team.id,
featureId: testFeature,
state: "disabled",
assignedBy: "test",
});
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: org.id,
teamIds: [team.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.teamStates).toEqual(["disabled"]);
expect(status.teamAutoOptIns).toEqual([true]);
expect(status.effectiveEnabled).toBe(false); // Explicit disabled takes precedence
});
});
describe("org autoOptInFeatures", () => {
it("should transform org state from 'inherit' to 'enabled' when org has autoOptInFeatures=true", async () => {
const { user, org, team, service, setupFeature } = await setup();
const testFeature = await setupFeature(true);
// Enable auto-opt-in for org
await prisma.team.update({
where: { id: org.id },
data: { autoOptInFeatures: true },
});
// Org has no explicit state (inherit), but autoOptInFeatures=true should enable
// Team inherits from org
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: org.id,
teamIds: [team.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.orgState).toBe("inherit"); // Raw state is still inherit
expect(status.orgAutoOptIn).toBe(true);
expect(status.effectiveEnabled).toBe(true); // Effective is true due to org auto-opt-in
});
it("should NOT transform explicit 'disabled' state when org has autoOptInFeatures=true", async () => {
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
const testFeature = await setupFeature(true);
// Enable auto-opt-in for org
await prisma.team.update({
where: { id: org.id },
data: { autoOptInFeatures: true },
});
// Org explicitly disables
await featuresRepository.setTeamFeatureState({
teamId: org.id,
featureId: testFeature,
state: "disabled",
assignedBy: "test",
});
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: org.id,
teamIds: [team.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.orgState).toBe("disabled");
expect(status.orgAutoOptIn).toBe(true);
expect(status.effectiveEnabled).toBe(false); // Explicit disabled takes precedence
});
});
describe("auto-opt-in with org disabled blocking", () => {
it("should return effectiveEnabled=false when user auto-opts-in but org is disabled", async () => {
const { user, org, team, service, setupFeature, featuresRepository } = await setup();
const testFeature = await setupFeature(true);
// Enable auto-opt-in for user
await prisma.user.update({
where: { id: user.id },
data: { autoOptInFeatures: true },
});
// Org explicitly disables
await featuresRepository.setTeamFeatureState({
teamId: org.id,
featureId: testFeature,
state: "disabled",
assignedBy: "test",
});
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: org.id,
teamIds: [team.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.orgState).toBe("disabled");
expect(status.userState).toBe("inherit");
expect(status.userAutoOptIn).toBe(true);
expect(status.effectiveEnabled).toBe(false); // Org disabled blocks everything
});
});
describe("auto-opt-in flags in response", () => {
it("should return correct auto-opt-in flags for all levels", async () => {
const { user, org, team, team2, service, setupFeature } = await setup();
const testFeature = await setupFeature(true);
// Set up different auto-opt-in states
await prisma.user.update({
where: { id: user.id },
data: { autoOptInFeatures: true },
});
await prisma.team.update({
where: { id: org.id },
data: { autoOptInFeatures: false },
});
await prisma.team.update({
where: { id: team.id },
data: { autoOptInFeatures: true },
});
await prisma.team.update({
where: { id: team2.id },
data: { autoOptInFeatures: false },
});
const statusMap = await service.resolveFeatureStatesAcrossTeams({
userId: user.id,
orgId: org.id,
teamIds: [team.id, team2.id],
featureIds: [testFeature],
});
const status = statusMap[testFeature];
expect(status.userAutoOptIn).toBe(true);
expect(status.orgAutoOptIn).toBe(false);
expect(status.teamAutoOptIns).toEqual([true, false]);
});
});
});
});
@@ -0,0 +1,232 @@
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}`,
});
}
}
}
+8
View File
@@ -38,4 +38,12 @@ export type AppFlags = {
export type TeamFeatures = Record<keyof AppFlags, boolean>;
/**
* Explicit state for API/UI layer.
* - "enabled": row with enabled = true
* - "disabled": row with enabled = false
* - "inherit": no row
*/
export type FeatureState = "enabled" | "disabled" | "inherit";
export type FeatureId = keyof AppFlags;
File diff suppressed because it is too large Load Diff
@@ -1,20 +1,59 @@
import type { AppFlags, FeatureState } from "./config";
import type { FeatureId, FeatureState } from "./config";
/**
* Interface for the core FeaturesRepository.
* This interface defines methods for checking feature flags and team feature access.
*/
export interface IFeaturesRepository {
checkIfFeatureIsEnabledGlobally(slug: keyof AppFlags): Promise<boolean>;
checkIfFeatureIsEnabledGlobally(slug: FeatureId): Promise<boolean>;
checkIfUserHasFeature(userId: number, slug: string): Promise<boolean>;
getUserFeaturesStatus(userId: number, slugs: string[]): Promise<Record<string, boolean>>;
checkIfUserHasFeatureNonHierarchical(userId: number, slug: string): Promise<boolean>;
checkIfTeamHasFeature(teamId: number, slug: keyof AppFlags): Promise<boolean>;
getTeamsWithFeatureEnabled(slug: keyof AppFlags): Promise<number[]>;
setTeamFeatureState(
teamId: number,
featureId: keyof AppFlags,
state: FeatureState,
assignedBy: string
checkIfTeamHasFeature(teamId: number, slug: FeatureId): Promise<boolean>;
getTeamsWithFeatureEnabled(slug: FeatureId): Promise<number[]>;
setUserFeatureState(
input:
| { userId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: string }
| { userId: number; featureId: FeatureId; state: "inherit" }
): Promise<void>;
setTeamFeatureState(
input:
| { teamId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: string }
| { teamId: number; featureId: FeatureId; state: "inherit" }
): Promise<void>;
/**
* Get user's feature states for multiple features.
* @returns Record<featureId, 'enabled' | 'disabled' | 'inherit'>
*/
getUserFeatureStates(input: {
userId: number;
featureIds: FeatureId[];
}): Promise<Record<string, FeatureState>>;
/**
* Get multiple features' states across multiple teams.
* Optimized for querying many teams for many features.
* @returns Record<featureId, Record<teamId, 'enabled' | 'disabled' | 'inherit'>>
*/
getTeamsFeatureStates(input: {
teamIds: number[];
featureIds: FeatureId[];
}): Promise<Record<string, Record<number, FeatureState>>>;
/**
* Get user's autoOptInFeatures flag.
* @returns Promise<boolean> - True if user has auto opt-in enabled
*/
getUserAutoOptIn(userId: number): Promise<boolean>;
/**
* Get autoOptInFeatures for multiple teams (batch).
* @returns Promise<Record<number, boolean>> - Map of teamId to autoOptInFeatures value
*/
getTeamsAutoOptIn(teamIds: number[]): Promise<Record<number, boolean>>;
/**
* Set user's autoOptInFeatures flag.
*/
setUserAutoOptIn(userId: number, enabled: boolean): Promise<void>;
/**
* Set team's autoOptInFeatures flag.
*/
setTeamAutoOptIn(teamId: number, enabled: boolean): Promise<void>;
}
@@ -1,37 +1,99 @@
import type { AppFlags, FeatureState } from "./config";
import type { FeatureId, FeatureState } from "./config";
import type { IFeaturesRepository } from "./features.repository.interface";
export class MockFeaturesRepository implements IFeaturesRepository {
async checkIfUserHasFeature(userId: number, slug: string) {
async checkIfUserHasFeature(_userId: number, slug: string) {
return slug === "mock-feature";
}
async getUserFeaturesStatus(userId: number, slugs: string[]): Promise<Record<string, boolean>> {
return Object.fromEntries(slugs.map((slug) => [slug, slug === "mock-feature"]));
async getUserFeaturesStatus(
_userId: number,
slugs: string[]
): Promise<Record<string, boolean>> {
return Object.fromEntries(
slugs.map((slug) => [slug, slug === "mock-feature"])
);
}
async checkIfUserHasFeatureNonHierarchical(userId: number, slug: string) {
async checkIfUserHasFeatureNonHierarchical(_userId: number, slug: string) {
return slug === "mock-feature";
}
async checkIfTeamHasFeature(teamId: number, slug: keyof AppFlags) {
async checkIfTeamHasFeature(_teamId: number, slug: FeatureId) {
return slug === "mock-feature";
}
async checkIfFeatureIsEnabledGlobally(_slug: keyof AppFlags) {
async checkIfFeatureIsEnabledGlobally(_slug: FeatureId) {
return true;
}
async getTeamsWithFeatureEnabled(_slug: keyof AppFlags): Promise<number[]> {
async getTeamsWithFeatureEnabled(_slug: FeatureId): Promise<number[]> {
return [];
}
async setTeamFeatureState(
_teamId: number,
_featureId: keyof AppFlags,
_state: FeatureState,
_assignedBy: string
async setUserFeatureState(
_input:
| {
userId: number;
featureId: FeatureId;
state: "enabled" | "disabled";
assignedBy: string;
}
| { userId: number; featureId: FeatureId; state: "inherit" }
): Promise<void> {
// Mock implementation - do nothing
}
async setTeamFeatureState(
_input:
| {
teamId: number;
featureId: FeatureId;
state: "enabled" | "disabled";
assignedBy: string;
}
| { teamId: number; featureId: FeatureId; state: "inherit" }
): Promise<void> {
// Mock implementation - do nothing
}
async getUserFeatureStates(_input: {
userId: number;
featureIds: FeatureId[];
}): Promise<Record<string, FeatureState>> {
// Mock implementation - return inherit for all features
const result: Record<string, FeatureState> = {};
for (const featureId of _input.featureIds) {
result[featureId] = "inherit";
}
return result;
}
async getTeamsFeatureStates(_input: {
teamIds: number[];
featureIds: FeatureId[];
}): Promise<Record<string, Record<number, FeatureState>>> {
// Mock implementation - return empty (all teams inherit)
return {};
}
async getUserAutoOptIn(_userId: number): Promise<boolean> {
// Mock implementation - default to false
return false;
}
async getTeamsAutoOptIn(
_teamIds: number[]
): Promise<Record<number, boolean>> {
// Mock implementation - return empty (all teams default to false)
return {};
}
async setUserAutoOptIn(_userId: number, _enabled: boolean): Promise<void> {
// Mock implementation - do nothing
}
async setTeamAutoOptIn(_teamId: number, _enabled: boolean): Promise<void> {
// Mock implementation - do nothing
}
}
+245 -15
View File
@@ -3,7 +3,7 @@ import { captureException } from "@sentry/nextjs";
import type { PrismaClient } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import type { AppFlags, FeatureState, TeamFeatures } from "./config";
import type { AppFlags, FeatureId, FeatureState, TeamFeatures } from "./config";
import type { IFeaturesRepository } from "./features.repository.interface";
interface CacheOptions {
@@ -55,7 +55,7 @@ export class FeaturesRepository implements IFeaturesRepository {
public async getFeatureFlagMap() {
const flags = await this.getAllFeatures();
return flags.reduce((acc, flag) => {
acc[flag.slug as keyof AppFlags] = flag.enabled;
acc[flag.slug as FeatureId] = flag.enabled;
return acc;
}, {} as AppFlags);
}
@@ -96,7 +96,7 @@ export class FeaturesRepository implements IFeaturesRepository {
* @throws Error if the feature flag check fails
*/
async checkIfFeatureIsEnabledGlobally(
slug: keyof AppFlags,
slug: FeatureId,
_options: CacheOptions = { ttl: 5 * 60 * 1000 }
): Promise<boolean> {
try {
@@ -333,24 +333,80 @@ export class FeaturesRepository implements IFeaturesRepository {
}
}
/**
* Updates a feature status for a specific user.
* Uses tri-state semantics:
* - 'enabled': creates/updates a row with enabled=true
* - 'disabled': creates/updates a row with enabled=false
* - 'inherit': deletes the row to inherit from team/org level
*
* @param input.userId - The ID of the user to update the feature for
* @param input.featureId - The feature identifier to update
* @param input.state - 'enabled' | 'disabled' | 'inherit'
* @param input.assignedBy - The user or what assigned the feature (required for enabled/disabled, not used for inherit)
* @returns Promise<void>
* @throws Error if the feature update fails
*/
async setUserFeatureState(
input:
| { userId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: string }
| { userId: number; featureId: FeatureId; state: "inherit" }
): Promise<void> {
const { userId, featureId, state } = input;
try {
if (state === "enabled" || state === "disabled") {
const { assignedBy } = input;
await this.prismaClient.userFeatures.upsert({
where: {
userId_featureId: {
userId,
featureId,
},
},
create: {
userId,
featureId,
enabled: state === "enabled",
assignedBy,
},
update: {
enabled: state === "enabled",
assignedBy,
},
});
} else if (state === "inherit") {
await this.prismaClient.userFeatures.deleteMany({
where: {
userId,
featureId,
},
});
}
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Updates a feature status for a specific team.
* Uses tri-state semantics: creates/updates a row with enabled=true.
* @param teamId - The ID of the team to enable the feature for
* @param featureId - The feature identifier to enable
* @param state - 'enabled' | 'disabled' | 'inherit'
* @param assignedBy - The user or what assigned the feature
* @param input.teamId - The ID of the team to enable the feature for
* @param input.featureId - The feature identifier to enable
* @param input.state - 'enabled' | 'disabled' | 'inherit'
* @param input.assignedBy - The user or what assigned the feature (required for enabled/disabled, not used for inherit)
* @returns Promise<void>
* @throws Error if the feature enabling fails
*/
async setTeamFeatureState(
teamId: number,
featureId: keyof AppFlags,
state: FeatureState,
assignedBy: string
input:
| { teamId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: string }
| { teamId: number; featureId: FeatureId; state: "inherit" }
): Promise<void> {
const { teamId, featureId, state } = input;
try {
if (state === "enabled" || state === "disabled") {
const { assignedBy } = input;
await this.prismaClient.teamFeatures.upsert({
where: {
teamId_featureId: {
@@ -377,8 +433,6 @@ export class FeaturesRepository implements IFeaturesRepository {
},
});
}
// Clear cache when features are modified
this.clearCache();
} catch (err) {
captureException(err);
throw err;
@@ -395,7 +449,7 @@ export class FeaturesRepository implements IFeaturesRepository {
* @returns Promise<boolean> - True if the team or any ancestor has the feature enabled, false otherwise
* @throws Error if the database query fails
*/
async checkIfTeamHasFeature(teamId: number, featureId: keyof AppFlags): Promise<boolean> {
async checkIfTeamHasFeature(teamId: number, featureId: FeatureId): Promise<boolean> {
try {
// Early return if team has feature directly assigned with enabled=true
const teamFeature = await this.prismaClient.teamFeatures.findUnique({
@@ -450,7 +504,7 @@ export class FeaturesRepository implements IFeaturesRepository {
}
}
async getTeamsWithFeatureEnabled(slug: keyof AppFlags): Promise<number[]> {
async getTeamsWithFeatureEnabled(slug: FeatureId): Promise<number[]> {
try {
// If globally disabled, treat as effectively disabled everywhere
const isGloballyEnabled = await this.checkIfFeatureIsEnabledGlobally(slug);
@@ -472,4 +526,180 @@ export class FeaturesRepository implements IFeaturesRepository {
throw err;
}
}
/**
* Get user's feature states for multiple features.
* Uses tri-state semantics:
* - Row with enabled=true 'enabled'
* - Row with enabled=false 'disabled'
* - No row 'inherit' from team/org level
*
* @param input - Object containing userId and featureIds array
* @returns Record<featureId, 'enabled' | 'disabled' | 'inherit'>
*/
async getUserFeatureStates(input: {
userId: number;
featureIds: FeatureId[];
}): Promise<Partial<Record<FeatureId, FeatureState>>> {
const { userId, featureIds } = input;
try {
// Initialize result with all features set to 'inherit'
const result: Partial<Record<FeatureId, FeatureState>> = {};
for (const featureId of featureIds) {
result[featureId] = "inherit";
}
// Query all user features in a single call
const userFeatures = await this.prismaClient.userFeatures.findMany({
where: {
userId,
featureId: { in: featureIds },
},
select: { featureId: true, enabled: true },
});
// Update result with actual values from database
for (const userFeature of userFeatures) {
result[userFeature.featureId as FeatureId] = userFeature.enabled ? "enabled" : "disabled";
}
return result;
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Get multiple features' states across multiple teams.
* Optimized for querying many teams for many features in one call.
* Uses tri-state semantics:
* - Row with enabled=true 'enabled'
* - Row with enabled=false 'disabled'
* - No row 'inherit' from parent team/org level
*
* @param input - Object containing teamIds array and featureIds array
* @returns Record<featureId, Record<teamId, 'enabled' | 'disabled' | 'inherit'>>
*/
async getTeamsFeatureStates(input: {
teamIds: number[];
featureIds: FeatureId[];
}): Promise<Partial<Record<FeatureId, Record<number, FeatureState>>>> {
const { teamIds, featureIds } = input;
if (teamIds.length === 0 || featureIds.length === 0) {
return {} as Partial<Record<FeatureId, Record<number, FeatureState>>>;
}
try {
// Initialize result with all features present to avoid undefined feature keys
const result: Partial<Record<FeatureId, Record<number, FeatureState>>> = {};
for (const featureId of featureIds) {
result[featureId] = {};
}
// Query all team features in a single call
const teamFeatures = await this.prismaClient.teamFeatures.findMany({
where: {
teamId: { in: teamIds },
featureId: { in: featureIds },
},
select: { teamId: true, featureId: true, enabled: true },
});
// Build result map - teams not in the result will default to 'inherit'
for (const teamFeature of teamFeatures) {
const featureStates = result[teamFeature.featureId as FeatureId] ?? {};
featureStates[teamFeature.teamId] = teamFeature.enabled ? "enabled" : "disabled";
result[teamFeature.featureId as FeatureId] = featureStates;
}
return result;
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Get user's autoOptInFeatures flag.
* @param userId - The ID of the user
* @returns Promise<boolean> - True if user has auto opt-in enabled, false otherwise
*/
async getUserAutoOptIn(userId: number): Promise<boolean> {
try {
const user = await this.prismaClient.user.findUnique({
where: { id: userId },
select: { autoOptInFeatures: true },
});
return user?.autoOptInFeatures ?? false;
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Get autoOptInFeatures for multiple teams (batch).
* @param teamIds - Array of team IDs to query
* @returns Promise<Record<number, boolean>> - Map of teamId to autoOptInFeatures value
*/
async getTeamsAutoOptIn(teamIds: number[]): Promise<Record<number, boolean>> {
if (teamIds.length === 0) {
return {};
}
try {
const teams = await this.prismaClient.team.findMany({
where: { id: { in: teamIds } },
select: { id: true, autoOptInFeatures: true },
});
const result: Record<number, boolean> = {};
for (const team of teams) {
result[team.id] = team.autoOptInFeatures;
}
return result;
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Set user's autoOptInFeatures flag.
* @param userId - The ID of the user
* @param enabled - Whether to enable auto opt-in for all features
* @returns Promise<void>
*/
async setUserAutoOptIn(userId: number, enabled: boolean): Promise<void> {
try {
await this.prismaClient.user.update({
where: { id: userId },
data: { autoOptInFeatures: enabled },
});
} catch (err) {
captureException(err);
throw err;
}
}
/**
* Set team's autoOptInFeatures flag.
* @param teamId - The ID of the team
* @param enabled - Whether to enable auto opt-in for all features
* @returns Promise<void>
*/
async setTeamAutoOptIn(teamId: number, enabled: boolean): Promise<void> {
try {
await this.prismaClient.team.update({
where: { id: teamId },
data: { autoOptInFeatures: enabled },
});
} catch (err) {
captureException(err);
throw err;
}
}
}
@@ -2,6 +2,9 @@ import prismock from "../../../../tests/libs/__mocks__/prisma";
import { expect, it } from "vitest";
import type { FeatureId } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { checkIfUserHasFeatureController } from "./check-if-user-has-feature.controller";
/**
@@ -9,15 +12,13 @@ import { checkIfUserHasFeatureController } from "./check-if-user-has-feature.con
* this test is identical to the test in the use case.
*/
it("checks if user has access to feature", async () => {
const featuresRepository = new FeaturesRepository(prismock);
const userId = 1;
await prismock.userFeatures.create({
data: {
userId,
featureId: "mock-feature",
assignedBy: "1",
updatedAt: new Date(),
enabled: true,
},
await featuresRepository.setUserFeatureState({
userId,
featureId: "mock-feature" as FeatureId,
state: "enabled",
assignedBy: "1",
});
await expect(checkIfUserHasFeatureController(userId, "nonexistent-feature")).resolves.toBe(false);
await expect(checkIfUserHasFeatureController(userId, "mock-feature")).resolves.toBe(true);
@@ -2,20 +2,21 @@ import prismock from "../../../../tests/libs/__mocks__/prisma";
import { expect, it } from "vitest";
import type { FeatureId } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { checkIfUserHasFeatureUseCase } from "./check-if-user-has-feature.use-case";
// This is identical to the test in the controller since the controller currently
// doesn't run any authentication checks or input validation.
it("returns if user has access to feature", async () => {
const featuresRepository = new FeaturesRepository(prismock);
const userId = 1;
await prismock.userFeatures.create({
data: {
userId,
featureId: "mock-feature",
assignedBy: "1",
updatedAt: new Date(),
enabled: true,
},
await featuresRepository.setUserFeatureState({
userId,
featureId: "mock-feature" as FeatureId,
state: "enabled",
assignedBy: "1",
});
await expect(checkIfUserHasFeatureUseCase(userId, "nonexistent-feature")).resolves.toBe(false);
await expect(checkIfUserHasFeatureUseCase(userId, "mock-feature")).resolves.toBe(true);
@@ -531,6 +531,7 @@ export class MembershipRepository {
select: {
id: true,
parentId: true,
isOrganization: true,
},
},
},
@@ -1,5 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, beforeAll } from "vitest";
import type { FeatureId } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -8,12 +10,14 @@ import { PermissionRepository } from "../PermissionRepository";
describe("PermissionRepository - Integration Tests", () => {
let repository: PermissionRepository;
let featuresRepository: FeaturesRepository;
let testRoleId: string;
let testUserId: number;
let testTeamId: number;
beforeAll(async () => {
repository = new PermissionRepository(prisma);
featuresRepository = new FeaturesRepository(prisma);
});
beforeEach(async () => {
@@ -58,8 +62,10 @@ describe("PermissionRepository - Integration Tests", () => {
await prisma.role.deleteMany({
where: { id: testRoleId },
});
await prisma.teamFeatures.deleteMany({
where: { teamId: testTeamId },
await featuresRepository.setTeamFeatureState({
teamId: testTeamId,
featureId: "pbac" as FeatureId,
state: "inherit",
});
await prisma.team.deleteMany({
where: { id: testTeamId },
@@ -340,13 +346,11 @@ describe("PermissionRepository - Integration Tests", () => {
it("should return team IDs for PBAC-enabled team with matching permissions", async () => {
// Enable PBAC for the team
await prisma.teamFeatures.create({
data: {
teamId: testTeamId,
featureId: "pbac",
assignedBy: "test",
enabled: true,
},
await featuresRepository.setTeamFeatureState({
teamId: testTeamId,
featureId: "pbac" as FeatureId,
state: "enabled",
assignedBy: "test",
});
// Create membership with custom role
@@ -386,13 +390,11 @@ describe("PermissionRepository - Integration Tests", () => {
it("should not return team IDs when permissions do not match", async () => {
// Enable PBAC for the team
await prisma.teamFeatures.create({
data: {
teamId: testTeamId,
featureId: "pbac",
assignedBy: "test",
enabled: true,
},
await featuresRepository.setTeamFeatureState({
teamId: testTeamId,
featureId: "pbac" as FeatureId,
state: "enabled",
assignedBy: "test",
});
// Create membership with custom role
@@ -484,13 +486,11 @@ describe("PermissionRepository - Integration Tests", () => {
});
// Enable PBAC for org
await prisma.teamFeatures.create({
data: {
teamId: org.id,
featureId: "pbac",
assignedBy: "test",
enabled: true,
},
await featuresRepository.setTeamFeatureState({
teamId: org.id,
featureId: "pbac" as FeatureId,
state: "enabled",
assignedBy: "test",
});
// Create org role
@@ -547,7 +547,11 @@ describe("PermissionRepository - Integration Tests", () => {
await prisma.rolePermission.deleteMany({ where: { roleId: orgRole.id } });
await prisma.membership.deleteMany({ where: { userId: testUserId } });
await prisma.role.deleteMany({ where: { id: orgRole.id } });
await prisma.teamFeatures.deleteMany({ where: { teamId: org.id } });
await featuresRepository.setTeamFeatureState({
teamId: org.id,
featureId: "pbac" as FeatureId,
state: "inherit",
});
await prisma.team.deleteMany({ where: { id: { in: [org.id, childTeam.id] } } });
});
@@ -599,13 +603,11 @@ describe("PermissionRepository - Integration Tests", () => {
it("should handle wildcard permissions for PBAC teams", async () => {
// Enable PBAC for the team
await prisma.teamFeatures.create({
data: {
teamId: testTeamId,
featureId: "pbac",
assignedBy: "test",
enabled: true,
},
await featuresRepository.setTeamFeatureState({
teamId: testTeamId,
featureId: "pbac" as FeatureId,
state: "enabled",
assignedBy: "test",
});
// Create membership with custom role
@@ -645,13 +647,11 @@ describe("PermissionRepository - Integration Tests", () => {
it("should require all permissions to match (not just some)", async () => {
// Enable PBAC for the team
await prisma.teamFeatures.create({
data: {
teamId: testTeamId,
featureId: "pbac",
assignedBy: "test",
enabled: true,
},
await featuresRepository.setTeamFeatureState({
teamId: testTeamId,
featureId: "pbac" as FeatureId,
state: "enabled",
assignedBy: "test",
});
// Create membership with custom role
@@ -685,13 +685,11 @@ describe("PermissionRepository - Integration Tests", () => {
it("should not return teams for non-accepted memberships", async () => {
// Enable PBAC for the team
await prisma.teamFeatures.create({
data: {
teamId: testTeamId,
featureId: "pbac",
assignedBy: "test",
enabled: true,
},
await featuresRepository.setTeamFeatureState({
teamId: testTeamId,
featureId: "pbac" as FeatureId,
state: "enabled",
assignedBy: "test",
});
// Create membership with accepted: false
@@ -755,12 +753,20 @@ describe("PermissionRepository - Integration Tests", () => {
});
// Enable PBAC for both teams
await prisma.teamFeatures.createMany({
data: [
{ teamId: team1.id, featureId: "pbac", assignedBy: "test", enabled: true },
{ teamId: team2.id, featureId: "pbac", assignedBy: "test", enabled: true },
],
});
await Promise.all([
featuresRepository.setTeamFeatureState({
teamId: team1.id,
featureId: "pbac" as FeatureId,
state: "enabled",
assignedBy: "test",
}),
featuresRepository.setTeamFeatureState({
teamId: team2.id,
featureId: "pbac" as FeatureId,
state: "enabled",
assignedBy: "test",
}),
]);
// Create memberships for both teams
const membership1 = await prisma.membership.create({
@@ -825,7 +831,18 @@ describe("PermissionRepository - Integration Tests", () => {
await prisma.rolePermission.deleteMany({ where: { roleId: { in: [role1.id, role2.id] } } });
await prisma.membership.deleteMany({ where: { userId: testUserId } });
await prisma.role.deleteMany({ where: { id: { in: [role1.id, role2.id] } } });
await prisma.teamFeatures.deleteMany({ where: { teamId: { in: [team1.id, team2.id] } } });
await Promise.all([
featuresRepository.setTeamFeatureState({
teamId: team1.id,
featureId: "pbac" as FeatureId,
state: "inherit",
}),
featuresRepository.setTeamFeatureState({
teamId: team2.id,
featureId: "pbac" as FeatureId,
state: "inherit",
}),
]);
await prisma.team.deleteMany({ where: { id: { in: [team1.id, team2.id] } } });
});
@@ -855,13 +872,11 @@ describe("PermissionRepository - Integration Tests", () => {
});
// Enable PBAC for first team only
await prisma.teamFeatures.create({
data: {
teamId: team1.id,
featureId: "pbac",
assignedBy: "test",
enabled: true,
},
await featuresRepository.setTeamFeatureState({
teamId: team1.id,
featureId: "pbac" as FeatureId,
state: "enabled",
assignedBy: "test",
});
// Create PBAC membership
@@ -914,7 +929,11 @@ describe("PermissionRepository - Integration Tests", () => {
await prisma.rolePermission.deleteMany({ where: { roleId: role1.id } });
await prisma.membership.deleteMany({ where: { userId: testUserId } });
await prisma.role.deleteMany({ where: { id: role1.id } });
await prisma.teamFeatures.deleteMany({ where: { teamId: team1.id } });
await featuresRepository.setTeamFeatureState({
teamId: team1.id,
featureId: "pbac" as FeatureId,
state: "inherit",
});
await prisma.team.deleteMany({ where: { id: { in: [team1.id, team2.id] } } });
});
@@ -2,6 +2,8 @@ import prismock from "../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, beforeEach } from "vitest";
import type { FeatureId } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -37,13 +39,12 @@ describe("SelectedCalendarRepository", () => {
},
});
await prisma.teamFeatures.create({
data: {
teamId: team.id,
featureId: "calendar-cache",
enabled: false,
assignedBy: "test",
},
const featuresRepository = new FeaturesRepository(prismock);
await featuresRepository.setTeamFeatureState({
teamId: team.id,
featureId: "calendar-cache" as FeatureId,
state: "disabled",
assignedBy: "test",
});
await prisma.selectedCalendar.create({
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "public"."Team" ADD COLUMN "autoOptInFeatures" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "public"."users" ADD COLUMN "autoOptInFeatures" BOOLEAN NOT NULL DEFAULT false;
+4
View File
@@ -484,6 +484,8 @@ model User {
agents Agent[]
holidaySettings UserHolidaySettings?
autoOptInFeatures Boolean @default(false)
@@unique([email])
@@unique([email, username])
@@unique([username, organizationId])
@@ -615,6 +617,8 @@ model Team {
teamBilling TeamBilling? @relation("TeamBilling")
organizationBilling OrganizationBilling? @relation("OrganizationBilling")
autoOptInFeatures Boolean @default(false)
@@unique([slug, parentId])
@@index([parentId])
}
@@ -22,6 +22,7 @@ import { deploymentSetupRouter } from "./deploymentSetup/_router";
import { dsyncRouter } from "./dsync/_router";
import { eventTypesRouter } from "./eventTypes/_router";
import { eventTypesRouter as heavyEventTypesRouter } from "./eventTypes/heavy/_router";
import { featureOptInRouter } from "./featureOptIn/_router";
import { filterSegmentsRouter } from "./filterSegments/_router";
import { googleWorkspaceRouter } from "./googleWorkspace/_router";
import { holidaysRouter } from "./holidays/_router";
@@ -76,6 +77,7 @@ export const viewerRouter = router({
// After that there would just one merge call here for all the apps.
appRoutingForms: app_RoutingForms,
features: featureFlagRouter,
featureOptIn: featureOptInRouter,
users: userAdminRouter,
oAuth: oAuthRouter,
googleWorkspace: googleWorkspaceRouter,
@@ -1,3 +1,5 @@
import type { FeatureId } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../types";
@@ -15,22 +17,12 @@ export const assignFeatureToTeamHandler = async ({ ctx, input }: AssignFeatureOp
const { prisma, user } = ctx;
const { teamId, featureId } = input;
await prisma.teamFeatures.upsert({
where: {
teamId_featureId: {
teamId,
featureId,
},
},
create: {
teamId,
featureId,
assignedBy: `user:${user.id}`,
enabled: true,
},
update: {
enabled: true,
},
const featuresRepository = new FeaturesRepository(prisma);
await featuresRepository.setTeamFeatureState({
teamId,
featureId: featureId as FeatureId,
state: "enabled",
assignedBy: `user:${user.id}`,
});
return { success: true };
@@ -1,3 +1,5 @@
import type { FeatureId } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../types";
@@ -15,13 +17,11 @@ export const unassignFeatureFromTeamHandler = async ({ ctx, input }: UnassignFea
const { prisma } = ctx;
const { teamId, featureId } = input;
await prisma.teamFeatures.delete({
where: {
teamId_featureId: {
teamId,
featureId,
},
},
const featuresRepository = new FeaturesRepository(prisma);
await featuresRepository.setTeamFeatureState({
teamId,
featureId: featureId as FeatureId,
state: "inherit",
});
return { success: true };
@@ -0,0 +1,248 @@
import { z } from "zod";
import { isOptInFeature } from "@calcom/features/feature-opt-in/config";
import { FeatureOptInService } from "@calcom/features/feature-opt-in/services/FeatureOptInService";
import { 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 { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
const featureStateSchema = z.enum(["enabled", "disabled", "inherit"]);
const featuresRepository = new FeaturesRepository(prisma);
const featureOptInService = new FeatureOptInService(featuresRepository);
/**
* Helper to get user's org and team IDs from their memberships.
* Returns orgId (if user belongs to an org) and teamIds (non-org teams).
*/
async function getUserOrgAndTeamIds(userId: number): Promise<{ orgId: number | null; teamIds: number[] }> {
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 };
}
export const featureOptInRouter = router({
/**
* Get all opt-in features with states for current user.
* This considers all teams/orgs the user belongs to.
*/
listForUser: authedProcedure.query(async ({ ctx }) => {
const { orgId, teamIds } = await getUserOrgAndTeamIds(ctx.user.id);
return featureOptInService.listFeaturesForUser({
userId: ctx.user.id,
orgId,
teamIds,
});
}),
/**
* Get all opt-in features with states for a team settings page.
* Used by team admins to configure feature opt-in for their team.
*/
listForTeam: authedProcedure
.input(
z.object({
teamId: z.number(),
})
)
.query(async ({ ctx, input }) => {
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: input.teamId,
permission: "team.read",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to view team feature settings.",
});
}
return featureOptInService.listFeaturesForTeam({ teamId: input.teamId });
}),
/**
* Get all opt-in features with states for organization settings page.
* Used by org admins to configure feature opt-in for their organization.
* Uses the organization from the current user's context.
*/
listForOrganization: authedProcedure.query(async ({ ctx }) => {
const organizationId = ctx.user.organizationId;
if (!organizationId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You are not a member of any organization.",
});
}
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: organizationId,
permission: "organization.read",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to view organization feature settings.",
});
}
// Organizations use the same listFeaturesForTeam since they're stored in TeamFeatures
return featureOptInService.listFeaturesForTeam({ teamId: organizationId });
}),
/**
* Set user's feature state.
*/
setUserState: authedProcedure
.input(
z.object({
featureId: z.string(),
state: featureStateSchema,
})
)
.mutation(async ({ ctx, input }) => {
if (!isOptInFeature(input.featureId)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid featureId. This feature is not opt-in configurable.",
});
}
await featureOptInService.setUserFeatureState({
userId: ctx.user.id,
featureId: input.featureId,
state: input.state,
assignedBy: ctx.user.id,
});
return { success: true };
}),
/**
* Set team's feature state (requires team admin).
*/
setTeamState: authedProcedure
.input(
z.object({
teamId: z.number(),
featureId: z.string(),
state: featureStateSchema,
})
)
.mutation(async ({ ctx, input }) => {
if (!isOptInFeature(input.featureId)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid featureId. This feature is not opt-in configurable.",
});
}
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: input.teamId,
permission: "team.update",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to update team feature settings.",
});
}
await featureOptInService.setTeamFeatureState({
teamId: input.teamId,
featureId: input.featureId,
state: input.state,
assignedBy: ctx.user.id,
});
return { success: true };
}),
/**
* Set organization's feature state (requires org admin).
* Uses the organization from the current user's context.
*/
setOrganizationState: authedProcedure
.input(
z.object({
featureId: z.string(),
state: featureStateSchema,
})
)
.mutation(async ({ ctx, input }) => {
if (!isOptInFeature(input.featureId)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid featureId. This feature is not opt-in configurable.",
});
}
const organizationId = ctx.user.organizationId;
if (!organizationId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You are not a member of any organization.",
});
}
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: organizationId,
permission: "organization.update",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
if (!hasPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to update organization feature settings.",
});
}
// Organizations use the same TeamFeatures table
await featureOptInService.setTeamFeatureState({
teamId: organizationId,
featureId: input.featureId,
state: input.state,
assignedBy: ctx.user.id,
});
return { success: true };
}),
});
@@ -249,7 +249,12 @@ export const permissionsRouter = router({
}
// Enable PBAC feature for the organization
await featureRepo.setTeamFeatureState(orgId, "pbac", "enabled", "opt-in by user: " + ctx.user.id);
await featureRepo.setTeamFeatureState({
teamId: orgId,
featureId: "pbac",
state: "enabled",
assignedBy: "opt-in by user: " + ctx.user.id,
});
return { success: true, message: "PBAC enabled successfully" };
}),
@@ -7,6 +7,9 @@ const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function main() {
// Dynamic import for ES module
const { FeaturesRepository } = await import("@calcom/features/flags/features.repository");
const featuresRepository = new FeaturesRepository(prisma);
// Parse newEmail from args
const newEmail = process.argv[2] || "hariom@cal.com";
console.log(`Using newEmail: ${newEmail}`);
@@ -59,14 +62,11 @@ async function main() {
},
});
if (!delegationFeature) {
await prisma.teamFeatures.create({
data: {
teamId: org.id,
featureId: "delegation-credential",
assignedAt: new Date(),
assignedBy: "prepare-local-script",
enabled: true,
},
await featuresRepository.setTeamFeatureState({
teamId: org.id,
featureId: "delegation-credential",
state: "enabled",
assignedBy: "prepare-local-script",
});
console.log("Created TeamFeatures: delegation-credential");
} else {
@@ -84,14 +84,11 @@ async function main() {
},
});
if (!calendarCacheFeature) {
await prisma.teamFeatures.create({
data: {
teamId: org.id,
featureId: "calendar-cache",
assignedAt: new Date(),
assignedBy: "prepare-local-script",
enabled: true,
},
await featuresRepository.setTeamFeatureState({
teamId: org.id,
featureId: "calendar-cache",
state: "enabled",
assignedBy: "prepare-local-script",
});
console.log("Created TeamFeatures: calendar-cache");
} else {
+8 -8
View File
@@ -1,5 +1,7 @@
import { uuid } from "short-uuid";
import type { FeatureId } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import prisma from "@calcom/prisma";
@@ -51,14 +53,12 @@ export async function createPBACOrganization() {
});
// Add the feature flag
await prisma.teamFeatures.create({
data: {
featureId: "pbac",
teamId: organization.id,
assignedBy: "system (Seed script)",
assignedAt: new Date(),
enabled: true,
},
const featuresRepository = new FeaturesRepository(prisma);
await featuresRepository.setTeamFeatureState({
teamId: organization.id,
featureId: "pbac" as FeatureId,
state: "enabled",
assignedBy: "system (Seed script)",
});
console.log(`✅ Created organization: ${organization.name} (ID: ${organization.id})`);