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:
co-authored by
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
750408ec21
commit
f4248bf20d
+5
-1
@@ -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({
|
||||
|
||||
+5
-1
@@ -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({
|
||||
|
||||
+5
-1
@@ -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({
|
||||
|
||||
+5
-1
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+79
-60
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
Reference in New Issue
Block a user