From f4248bf20df5286e428bde4b2787580d2b2f494c Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Tue, 6 Jan 2026 16:55:53 +0100 Subject: [PATCH] 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 * refactor: rename updateFeatureForUser to setUserFeatureState Rename to match the convention used for setTeamFeatureState Co-Authored-By: eunjae@cal.com * 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 - 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 * 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 * 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 * refactor: use team.isOrganization and clarify computeEffectiveState comment Co-Authored-By: eunjae@cal.com * refactor: use MembershipRepository.findAllByUserId with isOrganization Co-Authored-By: eunjae@cal.com * feat: add featureId validation using isOptInFeature type guard Co-Authored-By: eunjae@cal.com * 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 * 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> --- ...organizations-roles.controller.e2e-spec.ts | 6 +- ...s-roles-permissions.controller.e2e-spec.ts | 6 +- ...zations-teams-roles.controller.e2e-spec.ts | 6 +- ...s-roles-permissions.controller.e2e-spec.ts | 6 +- .../repository/features.repository.fixture.ts | 69 +- apps/web/playwright/fixtures/users.ts | 43 +- apps/web/playwright/lib/test-helpers/pbac.ts | 16 +- .../app-store/delegationCredential.test.ts | 4 +- .../service/booking-audit.integration-test.ts | 134 ++- packages/features/feature-opt-in/config.ts | 35 + .../feature-opt-in/lib/applyAutoOptIn.test.ts | 188 ++++ .../feature-opt-in/lib/applyAutoOptIn.ts | 42 + .../lib/computeEffectiveState.test.ts | 352 +++++++ .../lib/computeEffectiveState.ts | 48 + .../FeatureOptInService.integration-test.ts | 908 ++++++++++++++++ .../services/FeatureOptInService.ts | 232 +++++ packages/features/flags/config.ts | 8 + .../features.repository.integration-test.ts | 968 +++++++++++++++--- .../flags/features.repository.interface.ts | 57 +- .../flags/features.repository.mock.ts | 88 +- .../features/flags/features.repository.ts | 260 ++++- ...eck-if-user-has-feature.controller.test.ts | 17 +- ...check-if-user-has-feature.use-case.test.ts | 17 +- .../repositories/MembershipRepository.ts | 1 + .../PermissionRepository.integration-test.ts | 139 +-- .../repository/selectedCalendar.test.ts | 15 +- .../migration.sql | 5 + packages/prisma/schema.prisma | 4 + .../trpc/server/routers/viewer/_router.tsx | 2 + .../admin/assignFeatureToTeam.handler.ts | 24 +- .../admin/unassignFeatureFromTeam.handler.ts | 14 +- .../routers/viewer/featureOptIn/_router.ts | 248 +++++ .../server/routers/viewer/pbac/_router.tsx | 7 +- ...ocal-for-delegation-credentials-testing.js | 29 +- scripts/seed-pbac-organization.ts | 16 +- 35 files changed, 3564 insertions(+), 450 deletions(-) create mode 100644 packages/features/feature-opt-in/config.ts create mode 100644 packages/features/feature-opt-in/lib/applyAutoOptIn.test.ts create mode 100644 packages/features/feature-opt-in/lib/applyAutoOptIn.ts create mode 100644 packages/features/feature-opt-in/lib/computeEffectiveState.test.ts create mode 100644 packages/features/feature-opt-in/lib/computeEffectiveState.ts create mode 100644 packages/features/feature-opt-in/services/FeatureOptInService.integration-test.ts create mode 100644 packages/features/feature-opt-in/services/FeatureOptInService.ts create mode 100644 packages/prisma/migrations/20251217155117_add_auto_opt_in_features/migration.sql create mode 100644 packages/trpc/server/routers/viewer/featureOptIn/_router.ts diff --git a/apps/api/v2/src/modules/organizations/roles/organizations-roles.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/roles/organizations-roles.controller.e2e-spec.ts index 229761957f..5c6a236374 100644 --- a/apps/api/v2/src/modules/organizations/roles/organizations-roles.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/roles/organizations-roles.controller.e2e-spec.ts @@ -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({ diff --git a/apps/api/v2/src/modules/organizations/roles/permissions/organizations-roles-permissions.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/roles/permissions/organizations-roles-permissions.controller.e2e-spec.ts index bc04ac3d3a..382d8aa3ea 100644 --- a/apps/api/v2/src/modules/organizations/roles/permissions/organizations-roles-permissions.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/roles/permissions/organizations-roles-permissions.controller.e2e-spec.ts @@ -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({ diff --git a/apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.controller.e2e-spec.ts index 9aa1f9f42a..64bb8ba950 100644 --- a/apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/teams/roles/organizations-teams-roles.controller.e2e-spec.ts @@ -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({ diff --git a/apps/api/v2/src/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller.e2e-spec.ts index 637bcf10a1..a3d6774715 100644 --- a/apps/api/v2/src/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller.e2e-spec.ts @@ -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({ diff --git a/apps/api/v2/test/fixtures/repository/features.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/features.repository.fixture.ts index b5b964d44d..e024532da4 100644 --- a/apps/api/v2/test/fixtures/repository/features.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/features.repository.fixture.ts @@ -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", }); } } diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 012a2ff44a..ae3c69ec0e 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -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) { diff --git a/apps/web/playwright/lib/test-helpers/pbac.ts b/apps/web/playwright/lib/test-helpers/pbac.ts index 0535fcf64d..1c166d43d2 100644 --- a/apps/web/playwright/lib/test-helpers/pbac.ts +++ b/apps/web/playwright/lib/test-helpers/pbac.ts @@ -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", }); }; diff --git a/packages/app-store/delegationCredential.test.ts b/packages/app-store/delegationCredential.test.ts index 0198402b23..256caced3f 100644 --- a/packages/app-store/delegationCredential.test.ts +++ b/packages/app-store/delegationCredential.test.ts @@ -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 diff --git a/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts b/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts index ecaa883522..e4d8ad355b 100644 --- a/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts +++ b/packages/features/booking-audit/lib/service/booking-audit.integration-test.ts @@ -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", () => { }); }); }); - diff --git a/packages/features/feature-opt-in/config.ts b/packages/features/feature-opt-in/config.ts new file mode 100644 index 0000000000..61436bfe3e --- /dev/null +++ b/packages/features/feature-opt-in/config.ts @@ -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); +} diff --git a/packages/features/feature-opt-in/lib/applyAutoOptIn.test.ts b/packages/features/feature-opt-in/lib/applyAutoOptIn.test.ts new file mode 100644 index 0000000000..ceb86c30bf --- /dev/null +++ b/packages/features/feature-opt-in/lib/applyAutoOptIn.test.ts @@ -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"); + }); + }); +}); diff --git a/packages/features/feature-opt-in/lib/applyAutoOptIn.ts b/packages/features/feature-opt-in/lib/applyAutoOptIn.ts new file mode 100644 index 0000000000..6bb7a55806 --- /dev/null +++ b/packages/features/feature-opt-in/lib/applyAutoOptIn.ts @@ -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, + }; +} diff --git a/packages/features/feature-opt-in/lib/computeEffectiveState.test.ts b/packages/features/feature-opt-in/lib/computeEffectiveState.test.ts new file mode 100644 index 0000000000..94e1e02571 --- /dev/null +++ b/packages/features/feature-opt-in/lib/computeEffectiveState.test.ts @@ -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); + }); + }); +}); diff --git a/packages/features/feature-opt-in/lib/computeEffectiveState.ts b/packages/features/feature-opt-in/lib/computeEffectiveState.ts new file mode 100644 index 0000000000..8ac9c2af0c --- /dev/null +++ b/packages/features/feature-opt-in/lib/computeEffectiveState.ts @@ -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; +} diff --git a/packages/features/feature-opt-in/services/FeatureOptInService.integration-test.ts b/packages/features/feature-opt-in/services/FeatureOptInService.integration-test.ts new file mode 100644 index 0000000000..cad5c0bac3 --- /dev/null +++ b/packages/features/feature-opt-in/services/FeatureOptInService.integration-test.ts @@ -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> = []; + +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; +} + +/** + * 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 { + 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 => { + 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]); + }); + }); + }); +}); diff --git a/packages/features/feature-opt-in/services/FeatureOptInService.ts b/packages/features/feature-opt-in/services/FeatureOptInService.ts new file mode 100644 index 0000000000..bcc8f59379 --- /dev/null +++ b/packages/features/feature-opt-in/services/FeatureOptInService.ts @@ -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> { + // 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 = {}; + + 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}`, + }); + } + } +} diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 43d0e2033a..2b20975c91 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -38,4 +38,12 @@ export type AppFlags = { export type TeamFeatures = Record; +/** + * 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; diff --git a/packages/features/flags/features.repository.integration-test.ts b/packages/features/flags/features.repository.integration-test.ts index 9c340744c9..6a41bc732e 100644 --- a/packages/features/flags/features.repository.integration-test.ts +++ b/packages/features/flags/features.repository.integration-test.ts @@ -1,94 +1,102 @@ -import { describe, expect, beforeAll, afterAll, beforeEach, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { prisma } from "@calcom/prisma"; -import type { AppFlags } from "./config"; +import type { FeatureId } from "./config"; import { FeaturesRepository } from "./features.repository"; -const featuresRepository = new FeaturesRepository(prisma); +// Helper to generate unique identifiers per test +const uniqueId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; -// Access private clearCache method through type assertion -const clearCache = () => { - (featuresRepository as unknown as { clearCache: () => void }).clearCache(); -}; +// Store cleanup functions to ensure they run even if tests fail +const cleanupFunctions: Array<() => Promise> = []; -describe("FeaturesRepository Integration Tests", () => { - let testUser: { id: number }; - let testTeam: { id: number }; - const testFeature = "teams" as keyof AppFlags; +afterEach(async () => { + for (const cleanup of cleanupFunctions) { + await cleanup(); + } + cleanupFunctions.length = 0; +}); - beforeAll(async () => { - // Create test user - testUser = await prisma.user.create({ - data: { - email: `test-${Date.now()}@example.com`, - username: `test-${Date.now()}`, - }, - }); +interface TestEntities { + user: { id: number }; + team: { id: number }; + testFeature: FeatureId; + featuresRepository: FeaturesRepository; + clearCache: () => void; +} - // Create test team - testTeam = await prisma.team.create({ - data: { - name: `Test Team ${Date.now()}`, - slug: `test-team-${Date.now()}`, - }, - }); +/** + * Creates isolated test entities (user, team) 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 { + const id = uniqueId(); + const testFeature = `test-feature-${id}` as FeatureId; + + // Create test user + const user = await prisma.user.create({ + data: { + email: `test-${id}@example.com`, + username: `test-${id}`, + }, }); - afterAll(async () => { - // Clean up test data in correct order to respect foreign key constraints + // Create test team + const team = await prisma.team.create({ + data: { + name: `Test Team ${id}`, + slug: `test-team-${id}`, + }, + }); + + const featuresRepository = new FeaturesRepository(prisma); + + // Access private clearCache method through type assertion + const clearCache = () => { + (featuresRepository as unknown as { clearCache: () => void }).clearCache(); + }; + + // 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: testUser.id }, + where: { userId: user.id }, }); await prisma.teamFeatures.deleteMany({ - where: { teamId: testTeam.id }, + where: { teamId: team.id }, }); await prisma.feature.deleteMany({ where: { slug: testFeature }, }); await prisma.membership.deleteMany({ - where: { userId: testUser.id }, + where: { userId: user.id }, }); await prisma.team.deleteMany({ - where: { id: testTeam.id }, + where: { id: team.id }, }); await prisma.user.delete({ - where: { id: testUser.id }, + where: { id: user.id }, }); - }); + }; - beforeEach(async () => { - // Reset feature state before each test - await prisma.feature.deleteMany({ - where: { slug: testFeature }, - }); - await prisma.userFeatures.deleteMany({ - where: { userId: testUser.id }, - }); - await prisma.teamFeatures.deleteMany({ - where: { teamId: testTeam.id }, - }); - await prisma.membership.deleteMany({ - where: { teamId: testTeam.id }, - }); - await prisma.team.deleteMany({ - where: { id: testTeam.id }, - }); + // Register cleanup to run automatically after each test + cleanupFunctions.push(cleanup); - // Recreate test team - testTeam = await prisma.team.create({ - data: { - name: `Test Team ${Date.now()}`, - slug: `test-team-${Date.now()}`, - }, - }); - - // Clear the feature flag cache - clearCache(); - }); + return { + user, + team, + testFeature, + featuresRepository, + clearCache, + }; +} +describe("FeaturesRepository Integration Tests", () => { describe("checkIfFeatureIsEnabledGlobally", () => { it("should return false when feature is not enabled globally", async () => { + const { testFeature, featuresRepository } = await setup(); + // Verify database state const dbFeature = await prisma.feature.findUnique({ where: { slug: testFeature }, @@ -103,6 +111,8 @@ describe("FeaturesRepository Integration Tests", () => { }); it("should return true when feature is enabled globally", async () => { + const { testFeature, featuresRepository, clearCache } = await setup(); + // Create the feature with enabled set to true await prisma.feature.create({ data: { @@ -132,11 +142,15 @@ describe("FeaturesRepository Integration Tests", () => { describe("checkIfUserHasFeature", () => { it("should return false when user does not have feature", async () => { - const result = await featuresRepository.checkIfUserHasFeature(testUser.id, testFeature); + const { user, testFeature, featuresRepository } = await setup(); + + const result = await featuresRepository.checkIfUserHasFeature(user.id, testFeature); expect(result).toBe(false); }); it("should return false when no UserFeatures row and no TeamFeatures row exist (tri-state inheritance)", async () => { + const { user, team, testFeature, featuresRepository } = await setup(); + // Create the feature globally await prisma.feature.create({ data: { @@ -149,19 +163,21 @@ describe("FeaturesRepository Integration Tests", () => { // User is a member of a team await prisma.membership.create({ data: { - teamId: testTeam.id, - userId: testUser.id, + teamId: team.id, + userId: user.id, role: "MEMBER", accepted: true, }, }); // No UserFeatures row, no TeamFeatures row - should return false - const result = await featuresRepository.checkIfUserHasFeature(testUser.id, testFeature); + const result = await featuresRepository.checkIfUserHasFeature(user.id, testFeature); expect(result).toBe(false); }); it("should return true when user has feature directly assigned", async () => { + const { user, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -172,18 +188,20 @@ describe("FeaturesRepository Integration Tests", () => { await prisma.userFeatures.create({ data: { - userId: testUser.id, + userId: user.id, featureId: testFeature, assignedBy: "test", enabled: true, }, }); - const result = await featuresRepository.checkIfUserHasFeature(testUser.id, testFeature); + const result = await featuresRepository.checkIfUserHasFeature(user.id, testFeature); expect(result).toBe(true); }); it("should return false when user has feature with enabled=false (tri-state)", async () => { + const { user, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -194,18 +212,20 @@ describe("FeaturesRepository Integration Tests", () => { await prisma.userFeatures.create({ data: { - userId: testUser.id, + userId: user.id, featureId: testFeature, enabled: false, assignedBy: "test", }, }); - const result = await featuresRepository.checkIfUserHasFeature(testUser.id, testFeature); + const result = await featuresRepository.checkIfUserHasFeature(user.id, testFeature); expect(result).toBe(false); }); it("should return false when user has enabled=false even if team has feature (tri-state blocks inheritance)", async () => { + const { user, team, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -217,7 +237,7 @@ describe("FeaturesRepository Integration Tests", () => { // Team has the feature enabled await prisma.teamFeatures.create({ data: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, enabled: true, assignedBy: "test", @@ -227,8 +247,8 @@ describe("FeaturesRepository Integration Tests", () => { // User is a member of the team await prisma.membership.create({ data: { - teamId: testTeam.id, - userId: testUser.id, + teamId: team.id, + userId: user.id, role: "MEMBER", accepted: true, }, @@ -237,18 +257,20 @@ describe("FeaturesRepository Integration Tests", () => { // But user has explicitly disabled the feature await prisma.userFeatures.create({ data: { - userId: testUser.id, + userId: user.id, featureId: testFeature, enabled: false, assignedBy: "test", }, }); - const result = await featuresRepository.checkIfUserHasFeature(testUser.id, testFeature); + const result = await featuresRepository.checkIfUserHasFeature(user.id, testFeature); expect(result).toBe(false); }); it("should return true when user belongs to team with feature", async () => { + const { user, team, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -259,7 +281,7 @@ describe("FeaturesRepository Integration Tests", () => { await prisma.teamFeatures.create({ data: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, assignedBy: "test", enabled: true, @@ -268,18 +290,20 @@ describe("FeaturesRepository Integration Tests", () => { await prisma.membership.create({ data: { - teamId: testTeam.id, - userId: testUser.id, + teamId: team.id, + userId: user.id, role: "MEMBER", accepted: true, }, }); - const result = await featuresRepository.checkIfUserHasFeature(testUser.id, testFeature); + const result = await featuresRepository.checkIfUserHasFeature(user.id, testFeature); expect(result).toBe(true); }); it("should return false when user belongs to team with feature disabled (tri-state)", async () => { + const { user, team, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -291,7 +315,7 @@ describe("FeaturesRepository Integration Tests", () => { // Team has the feature explicitly disabled await prisma.teamFeatures.create({ data: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, enabled: false, assignedBy: "test", @@ -300,30 +324,32 @@ describe("FeaturesRepository Integration Tests", () => { await prisma.membership.create({ data: { - teamId: testTeam.id, - userId: testUser.id, + teamId: team.id, + userId: user.id, role: "MEMBER", accepted: true, }, }); - const result = await featuresRepository.checkIfUserHasFeature(testUser.id, testFeature); + const result = await featuresRepository.checkIfUserHasFeature(user.id, testFeature); expect(result).toBe(false); }); it("should return true when user belongs to team where parent team has feature", async () => { + const { user, team, testFeature, featuresRepository, clearCache } = await setup(); + // Create an organization const org = await prisma.team.create({ data: { - name: `Test Org ${Date.now()}`, - slug: `test-org-${Date.now()}`, + name: `Test Org ${uniqueId()}`, + slug: `test-org-${uniqueId()}`, isOrganization: true, }, }); // Make the test team part of the organization await prisma.team.update({ - where: { id: testTeam.id }, + where: { id: team.id }, data: { parentId: org.id }, }); @@ -349,33 +375,27 @@ describe("FeaturesRepository Integration Tests", () => { }, }); - // Check if user is already a member of the team - const existingMembership = await prisma.membership.findFirst({ - where: { - teamId: testTeam.id, - userId: testUser.id, + // Create membership + await prisma.membership.create({ + data: { + teamId: team.id, + userId: user.id, + role: "MEMBER", + accepted: true, }, }); - // Only create membership if it doesn't exist - if (!existingMembership) { - await prisma.membership.create({ - data: { - teamId: testTeam.id, - userId: testUser.id, - role: "MEMBER", - accepted: true, - }, - }); - } - - const result = await featuresRepository.checkIfUserHasFeature(testUser.id, testFeature); + const result = await featuresRepository.checkIfUserHasFeature(user.id, testFeature); expect(result).toBe(true); // Clean up organization await prisma.teamFeatures.deleteMany({ where: { teamId: org.id }, }); + await prisma.team.update({ + where: { id: team.id }, + data: { parentId: null }, + }); await prisma.team.delete({ where: { id: org.id }, }); @@ -384,11 +404,15 @@ describe("FeaturesRepository Integration Tests", () => { describe("checkIfTeamHasFeature", () => { it("should return false when team does not have feature", async () => { - const result = await featuresRepository.checkIfTeamHasFeature(testTeam.id, testFeature); + const { team, testFeature, featuresRepository } = await setup(); + + const result = await featuresRepository.checkIfTeamHasFeature(team.id, testFeature); expect(result).toBe(false); }); it("should return true when team has feature directly assigned", async () => { + const { team, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -399,18 +423,20 @@ describe("FeaturesRepository Integration Tests", () => { await prisma.teamFeatures.create({ data: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, assignedBy: "test", enabled: true, }, }); - const result = await featuresRepository.checkIfTeamHasFeature(testTeam.id, testFeature); + const result = await featuresRepository.checkIfTeamHasFeature(team.id, testFeature); expect(result).toBe(true); }); it("should return false when team has feature with enabled=false (tri-state)", async () => { + const { team, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -421,27 +447,29 @@ describe("FeaturesRepository Integration Tests", () => { await prisma.teamFeatures.create({ data: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, enabled: false, assignedBy: "test", }, }); - const result = await featuresRepository.checkIfTeamHasFeature(testTeam.id, testFeature); + const result = await featuresRepository.checkIfTeamHasFeature(team.id, testFeature); expect(result).toBe(false); }); it("should return true when parent team has feature", async () => { + const { team, testFeature, featuresRepository } = await setup(); + const parentTeam = await prisma.team.create({ data: { - name: `Parent Team ${Date.now()}`, - slug: `parent-team-${Date.now()}`, + name: `Parent Team ${uniqueId()}`, + slug: `parent-team-${uniqueId()}`, }, }); await prisma.team.update({ - where: { id: testTeam.id }, + where: { id: team.id }, data: { parentId: parentTeam.id }, }); @@ -462,28 +490,34 @@ describe("FeaturesRepository Integration Tests", () => { }, }); - const result = await featuresRepository.checkIfTeamHasFeature(testTeam.id, testFeature); + const result = await featuresRepository.checkIfTeamHasFeature(team.id, testFeature); expect(result).toBe(true); // Clean up parent team await prisma.teamFeatures.deleteMany({ where: { teamId: parentTeam.id }, }); + await prisma.team.update({ + where: { id: team.id }, + data: { parentId: null }, + }); await prisma.team.delete({ where: { id: parentTeam.id }, }); }); it("should return false when parent has feature with enabled=false (tri-state)", async () => { + const { team, testFeature, featuresRepository } = await setup(); + const parentTeam = await prisma.team.create({ data: { - name: `Parent Team ${Date.now()}`, - slug: `parent-team-${Date.now()}`, + name: `Parent Team ${uniqueId()}`, + slug: `parent-team-${uniqueId()}`, }, }); await prisma.team.update({ - where: { id: testTeam.id }, + where: { id: team.id }, data: { parentId: parentTeam.id }, }); @@ -505,13 +539,17 @@ describe("FeaturesRepository Integration Tests", () => { }, }); - const result = await featuresRepository.checkIfTeamHasFeature(testTeam.id, testFeature); + const result = await featuresRepository.checkIfTeamHasFeature(team.id, testFeature); expect(result).toBe(false); // Clean up parent team await prisma.teamFeatures.deleteMany({ where: { teamId: parentTeam.id }, }); + await prisma.team.update({ + where: { id: team.id }, + data: { parentId: null }, + }); await prisma.team.delete({ where: { id: parentTeam.id }, }); @@ -520,11 +558,15 @@ describe("FeaturesRepository Integration Tests", () => { describe("checkIfUserHasFeatureNonHierarchical", () => { it("should return false when user does not have feature", async () => { - const result = await featuresRepository.checkIfUserHasFeatureNonHierarchical(testUser.id, testFeature); + const { user, testFeature, featuresRepository } = await setup(); + + const result = await featuresRepository.checkIfUserHasFeatureNonHierarchical(user.id, testFeature); expect(result).toBe(false); }); it("should return true when user has feature directly assigned", async () => { + const { user, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -535,18 +577,20 @@ describe("FeaturesRepository Integration Tests", () => { await prisma.userFeatures.create({ data: { - userId: testUser.id, + userId: user.id, featureId: testFeature, enabled: true, assignedBy: "test", }, }); - const result = await featuresRepository.checkIfUserHasFeatureNonHierarchical(testUser.id, testFeature); + const result = await featuresRepository.checkIfUserHasFeatureNonHierarchical(user.id, testFeature); expect(result).toBe(true); }); it("should return false when user has feature with enabled=false (tri-state)", async () => { + const { user, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -557,18 +601,20 @@ describe("FeaturesRepository Integration Tests", () => { await prisma.userFeatures.create({ data: { - userId: testUser.id, + userId: user.id, featureId: testFeature, enabled: false, assignedBy: "test", }, }); - const result = await featuresRepository.checkIfUserHasFeatureNonHierarchical(testUser.id, testFeature); + const result = await featuresRepository.checkIfUserHasFeatureNonHierarchical(user.id, testFeature); expect(result).toBe(false); }); it("should return true when user belongs to direct team with feature (non-hierarchical)", async () => { + const { user, team, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -579,7 +625,7 @@ describe("FeaturesRepository Integration Tests", () => { await prisma.teamFeatures.create({ data: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, enabled: true, assignedBy: "test", @@ -588,18 +634,20 @@ describe("FeaturesRepository Integration Tests", () => { await prisma.membership.create({ data: { - teamId: testTeam.id, - userId: testUser.id, + teamId: team.id, + userId: user.id, role: "MEMBER", accepted: true, }, }); - const result = await featuresRepository.checkIfUserHasFeatureNonHierarchical(testUser.id, testFeature); + const result = await featuresRepository.checkIfUserHasFeatureNonHierarchical(user.id, testFeature); expect(result).toBe(true); }); it("should return false when user belongs to direct team with feature disabled (tri-state)", async () => { + const { user, team, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -610,7 +658,7 @@ describe("FeaturesRepository Integration Tests", () => { await prisma.teamFeatures.create({ data: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, enabled: false, assignedBy: "test", @@ -619,20 +667,22 @@ describe("FeaturesRepository Integration Tests", () => { await prisma.membership.create({ data: { - teamId: testTeam.id, - userId: testUser.id, + teamId: team.id, + userId: user.id, role: "MEMBER", accepted: true, }, }); - const result = await featuresRepository.checkIfUserHasFeatureNonHierarchical(testUser.id, testFeature); + const result = await featuresRepository.checkIfUserHasFeatureNonHierarchical(user.id, testFeature); expect(result).toBe(false); }); }); describe("setTeamFeatureState", () => { it("should create a new TeamFeatures row with enabled=true", async () => { + const { team, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -641,12 +691,17 @@ describe("FeaturesRepository Integration Tests", () => { }, }); - await featuresRepository.setTeamFeatureState(testTeam.id, testFeature, "enabled", "test-assigner"); + await featuresRepository.setTeamFeatureState({ + teamId: team.id, + featureId: testFeature, + state: "enabled", + assignedBy: "test-assigner", + }); const teamFeature = await prisma.teamFeatures.findUnique({ where: { teamId_featureId: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, }, }, @@ -658,6 +713,8 @@ describe("FeaturesRepository Integration Tests", () => { }); it("should update existing TeamFeatures row to enabled=true", async () => { + const { team, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -669,7 +726,7 @@ describe("FeaturesRepository Integration Tests", () => { // Create with enabled=false first await prisma.teamFeatures.create({ data: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, enabled: false, assignedBy: "original-assigner", @@ -677,12 +734,17 @@ describe("FeaturesRepository Integration Tests", () => { }); // Now enable the feature - await featuresRepository.setTeamFeatureState(testTeam.id, testFeature, "enabled", "new-assigner"); + await featuresRepository.setTeamFeatureState({ + teamId: team.id, + featureId: testFeature, + state: "enabled", + assignedBy: "new-assigner", + }); const teamFeature = await prisma.teamFeatures.findUnique({ where: { teamId_featureId: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, }, }, @@ -694,6 +756,8 @@ describe("FeaturesRepository Integration Tests", () => { }); it("should create a new TeamFeatures row with enabled=false", async () => { + const { team, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -702,12 +766,17 @@ describe("FeaturesRepository Integration Tests", () => { }, }); - await featuresRepository.setTeamFeatureState(testTeam.id, testFeature, "disabled", "test-assigner"); + await featuresRepository.setTeamFeatureState({ + teamId: team.id, + featureId: testFeature, + state: "disabled", + assignedBy: "test-assigner", + }); const teamFeature = await prisma.teamFeatures.findUnique({ where: { teamId_featureId: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, }, }, @@ -719,6 +788,8 @@ describe("FeaturesRepository Integration Tests", () => { }); it("should update existing TeamFeatures row to enabled=false", async () => { + const { team, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -730,7 +801,7 @@ describe("FeaturesRepository Integration Tests", () => { // Create with enabled=true first await prisma.teamFeatures.create({ data: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, enabled: true, assignedBy: "original-assigner", @@ -738,12 +809,17 @@ describe("FeaturesRepository Integration Tests", () => { }); // Now disable the feature - await featuresRepository.setTeamFeatureState(testTeam.id, testFeature, "disabled", "new-assigner"); + await featuresRepository.setTeamFeatureState({ + teamId: team.id, + featureId: testFeature, + state: "disabled", + assignedBy: "new-assigner", + }); const teamFeature = await prisma.teamFeatures.findUnique({ where: { teamId_featureId: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, }, }, @@ -755,6 +831,8 @@ describe("FeaturesRepository Integration Tests", () => { }); it("should delete TeamFeatures row when state is inherit", async () => { + const { team, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -766,7 +844,7 @@ describe("FeaturesRepository Integration Tests", () => { // Create a TeamFeatures row first await prisma.teamFeatures.create({ data: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, enabled: true, assignedBy: "original-assigner", @@ -777,7 +855,7 @@ describe("FeaturesRepository Integration Tests", () => { const beforeDelete = await prisma.teamFeatures.findUnique({ where: { teamId_featureId: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, }, }, @@ -785,12 +863,16 @@ describe("FeaturesRepository Integration Tests", () => { expect(beforeDelete).not.toBeNull(); // Now set to inherit - should delete the row - await featuresRepository.setTeamFeatureState(testTeam.id, testFeature, "inherit", "test-assigner"); + await featuresRepository.setTeamFeatureState({ + teamId: team.id, + featureId: testFeature, + state: "inherit", + }); const teamFeature = await prisma.teamFeatures.findUnique({ where: { teamId_featureId: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, }, }, @@ -800,6 +882,8 @@ describe("FeaturesRepository Integration Tests", () => { }); it("should handle inherit state when no TeamFeatures row exists", async () => { + const { team, testFeature, featuresRepository } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -809,12 +893,16 @@ describe("FeaturesRepository Integration Tests", () => { }); // Call inherit without any existing row - should not throw - await featuresRepository.setTeamFeatureState(testTeam.id, testFeature, "inherit", "test-assigner"); + await featuresRepository.setTeamFeatureState({ + teamId: team.id, + featureId: testFeature, + state: "inherit", + }); const teamFeature = await prisma.teamFeatures.findUnique({ where: { teamId_featureId: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, }, }, @@ -826,6 +914,8 @@ describe("FeaturesRepository Integration Tests", () => { describe("getTeamsWithFeatureEnabled", () => { it("should return empty array when no teams have the feature", async () => { + const { testFeature, featuresRepository, clearCache } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -841,6 +931,8 @@ describe("FeaturesRepository Integration Tests", () => { }); it("should return teams with enabled=true only", async () => { + const { team, testFeature, featuresRepository, clearCache } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -852,15 +944,15 @@ describe("FeaturesRepository Integration Tests", () => { // Create a second team const secondTeam = await prisma.team.create({ data: { - name: `Second Team ${Date.now()}`, - slug: `second-team-${Date.now()}`, + name: `Second Team ${uniqueId()}`, + slug: `second-team-${uniqueId()}`, }, }); - // testTeam has feature enabled + // team has feature enabled await prisma.teamFeatures.create({ data: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, enabled: true, assignedBy: "test", @@ -880,7 +972,7 @@ describe("FeaturesRepository Integration Tests", () => { clearCache(); const result = await featuresRepository.getTeamsWithFeatureEnabled(testFeature); - expect(result).toContain(testTeam.id); + expect(result).toContain(team.id); expect(result).not.toContain(secondTeam.id); // Clean up second team @@ -893,6 +985,8 @@ describe("FeaturesRepository Integration Tests", () => { }); it("should return empty array when feature is not globally enabled", async () => { + const { team, testFeature, featuresRepository, clearCache } = await setup(); + await prisma.feature.create({ data: { slug: testFeature, @@ -903,7 +997,7 @@ describe("FeaturesRepository Integration Tests", () => { await prisma.teamFeatures.create({ data: { - teamId: testTeam.id, + teamId: team.id, featureId: testFeature, enabled: true, assignedBy: "test", @@ -916,4 +1010,574 @@ describe("FeaturesRepository Integration Tests", () => { expect(result).toEqual([]); }); }); + + describe("setUserFeatureState", () => { + it("should create a new UserFeatures row with enabled=true when state is 'enabled'", async () => { + const { user, testFeature, featuresRepository } = await setup(); + + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + await featuresRepository.setUserFeatureState({ + userId: user.id, + featureId: testFeature, + state: "enabled", + assignedBy: "test-assigner", + }); + + const userFeature = await prisma.userFeatures.findFirst({ + where: { + userId: user.id, + featureId: testFeature, + }, + }); + + expect(userFeature).not.toBeNull(); + expect(userFeature?.enabled).toBe(true); + expect(userFeature?.assignedBy).toBe("test-assigner"); + }); + + it("should create a new UserFeatures row with enabled=false when state is 'disabled'", async () => { + const { user, testFeature, featuresRepository } = await setup(); + + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + await featuresRepository.setUserFeatureState({ + userId: user.id, + featureId: testFeature, + state: "disabled", + assignedBy: "test-assigner", + }); + + const userFeature = await prisma.userFeatures.findFirst({ + where: { + userId: user.id, + featureId: testFeature, + }, + }); + + expect(userFeature).not.toBeNull(); + expect(userFeature?.enabled).toBe(false); + expect(userFeature?.assignedBy).toBe("test-assigner"); + }); + + it("should update existing UserFeatures row", async () => { + const { user, testFeature, featuresRepository } = await setup(); + + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + // Create with enabled=false first + await prisma.userFeatures.create({ + data: { + userId: user.id, + featureId: testFeature, + enabled: false, + assignedBy: "original-assigner", + }, + }); + + // Now enable the feature + await featuresRepository.setUserFeatureState({ + userId: user.id, + featureId: testFeature, + state: "enabled", + assignedBy: "new-assigner", + }); + + const userFeature = await prisma.userFeatures.findFirst({ + where: { + userId: user.id, + featureId: testFeature, + }, + }); + + expect(userFeature).not.toBeNull(); + expect(userFeature?.enabled).toBe(true); + expect(userFeature?.assignedBy).toBe("new-assigner"); + }); + + it("should delete the row when state is 'inherit'", async () => { + const { user, testFeature, featuresRepository } = await setup(); + + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + // Create a row first + await prisma.userFeatures.create({ + data: { + userId: user.id, + featureId: testFeature, + enabled: true, + assignedBy: "test", + }, + }); + + // Set to inherit (should delete) + await featuresRepository.setUserFeatureState({ + userId: user.id, + featureId: testFeature, + state: "inherit", + }); + + const userFeature = await prisma.userFeatures.findFirst({ + where: { + userId: user.id, + featureId: testFeature, + }, + }); + + expect(userFeature).toBeNull(); + }); + }); + + describe("getUserFeatureStates", () => { + it("should return 'inherit' when user has no feature row", async () => { + const { user, testFeature, featuresRepository } = await setup(); + + const result = await featuresRepository.getUserFeatureStates({ + userId: user.id, + featureIds: [testFeature], + }); + + expect(result[testFeature]).toBe("inherit"); + }); + + it("should return 'enabled' when user has feature enabled", async () => { + const { user, testFeature, featuresRepository } = await setup(); + + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + await prisma.userFeatures.create({ + data: { + userId: user.id, + featureId: testFeature, + enabled: true, + assignedBy: "test", + }, + }); + + const result = await featuresRepository.getUserFeatureStates({ + userId: user.id, + featureIds: [testFeature], + }); + + expect(result[testFeature]).toBe("enabled"); + }); + + it("should return 'disabled' when user has feature disabled", async () => { + const { user, testFeature, featuresRepository } = await setup(); + + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + await prisma.userFeatures.create({ + data: { + userId: user.id, + featureId: testFeature, + enabled: false, + assignedBy: "test", + }, + }); + + const result = await featuresRepository.getUserFeatureStates({ + userId: user.id, + featureIds: [testFeature], + }); + + expect(result[testFeature]).toBe("disabled"); + }); + + it("should return states for multiple features in a single query", async () => { + const { user, testFeature, featuresRepository } = await setup(); + const testFeature2 = `${testFeature}-2` as FeatureId; + + await prisma.feature.createMany({ + data: [ + { slug: testFeature, enabled: true, type: "OPERATIONAL" }, + { slug: testFeature2, enabled: true, type: "OPERATIONAL" }, + ], + }); + + await prisma.userFeatures.createMany({ + data: [ + { userId: user.id, featureId: testFeature, enabled: true, assignedBy: "test" }, + { userId: user.id, featureId: testFeature2, enabled: false, assignedBy: "test" }, + ], + }); + + const result = await featuresRepository.getUserFeatureStates({ + userId: user.id, + featureIds: [testFeature, testFeature2], + }); + + expect(result[testFeature]).toBe("enabled"); + expect(result[testFeature2]).toBe("disabled"); + + // Clean up extra feature + await prisma.userFeatures.deleteMany({ + where: { userId: user.id, featureId: testFeature2 }, + }); + await prisma.feature.deleteMany({ + where: { slug: testFeature2 }, + }); + }); + }); + + describe("getTeamsFeatureStates", () => { + it("should return empty object for empty teamIds array", async () => { + const { testFeature, featuresRepository } = await setup(); + + const result = await featuresRepository.getTeamsFeatureStates({ + teamIds: [], + featureIds: [testFeature], + }); + + expect(result).toEqual({}); + }); + + it("should return inherit (missing key) when team has no feature row", async () => { + const { team, testFeature, featuresRepository } = await setup(); + + const result = await featuresRepository.getTeamsFeatureStates({ + teamIds: [team.id], + featureIds: [testFeature], + }); + + // Teams without explicit state are not in the result (caller should default to 'inherit') + expect(result[testFeature]?.[team.id]).toBeUndefined(); + }); + + it("should return 'enabled' when team has feature enabled", async () => { + const { team, testFeature, featuresRepository } = await setup(); + + await prisma.feature.create({ + data: { slug: testFeature, enabled: true, type: "OPERATIONAL" }, + }); + + await prisma.teamFeatures.create({ + data: { + teamId: team.id, + featureId: testFeature, + enabled: true, + assignedBy: "test", + }, + }); + + const result = await featuresRepository.getTeamsFeatureStates({ + teamIds: [team.id], + featureIds: [testFeature], + }); + + expect(result[testFeature]?.[team.id]).toBe("enabled"); + }); + + it("should return 'disabled' when team has feature disabled", async () => { + const { team, testFeature, featuresRepository } = await setup(); + + await prisma.feature.create({ + data: { slug: testFeature, enabled: true, type: "OPERATIONAL" }, + }); + + await prisma.teamFeatures.create({ + data: { + teamId: team.id, + featureId: testFeature, + enabled: false, + assignedBy: "test", + }, + }); + + const result = await featuresRepository.getTeamsFeatureStates({ + teamIds: [team.id], + featureIds: [testFeature], + }); + + expect(result[testFeature]?.[team.id]).toBe("disabled"); + }); + + it("should return states for multiple teams in a single query", async () => { + const { team, testFeature, featuresRepository } = await setup(); + + const secondTeam = await prisma.team.create({ + data: { name: `Second Test Team ${uniqueId()}`, slug: `second-test-team-${uniqueId()}` }, + }); + + await prisma.feature.create({ + data: { slug: testFeature, enabled: true, type: "OPERATIONAL" }, + }); + + await prisma.teamFeatures.createMany({ + data: [ + { teamId: team.id, featureId: testFeature, enabled: true, assignedBy: "test" }, + { teamId: secondTeam.id, featureId: testFeature, enabled: false, assignedBy: "test" }, + ], + }); + + const result = await featuresRepository.getTeamsFeatureStates({ + teamIds: [team.id, secondTeam.id], + featureIds: [testFeature], + }); + + expect(result[testFeature]?.[team.id]).toBe("enabled"); + expect(result[testFeature]?.[secondTeam.id]).toBe("disabled"); + + // Clean up + await prisma.teamFeatures.deleteMany({ + where: { teamId: secondTeam.id }, + }); + await prisma.team.delete({ + where: { id: secondTeam.id }, + }); + }); + + it("should only return teams with explicit state, not all requested teams", async () => { + const { team, testFeature, featuresRepository } = await setup(); + + const secondTeam = await prisma.team.create({ + data: { name: `Second Test Team ${uniqueId()}`, slug: `second-test-team-${uniqueId()}` }, + }); + + await prisma.feature.create({ + data: { slug: testFeature, enabled: true, type: "OPERATIONAL" }, + }); + + // Only set feature for team, not secondTeam + await prisma.teamFeatures.create({ + data: { + teamId: team.id, + featureId: testFeature, + enabled: true, + assignedBy: "test", + }, + }); + + const result = await featuresRepository.getTeamsFeatureStates({ + teamIds: [team.id, secondTeam.id], + featureIds: [testFeature], + }); + + expect(result[testFeature]?.[team.id]).toBe("enabled"); + expect(result[testFeature]?.[secondTeam.id]).toBeUndefined(); // Not in result, caller defaults to 'inherit' + + // Clean up + await prisma.team.delete({ + where: { id: secondTeam.id }, + }); + }); + + it("should return states for multiple features across teams", async () => { + const { team, testFeature, featuresRepository } = await setup(); + const secondFeature = `${testFeature}-two` as FeatureId; + + await prisma.feature.createMany({ + data: [ + { slug: testFeature, enabled: true, type: "OPERATIONAL" }, + { slug: secondFeature, enabled: true, type: "OPERATIONAL" }, + ], + }); + + await prisma.teamFeatures.createMany({ + data: [ + { teamId: team.id, featureId: testFeature, enabled: true, assignedBy: "test" }, + { teamId: team.id, featureId: secondFeature, enabled: false, assignedBy: "test" }, + ], + }); + + const result = await featuresRepository.getTeamsFeatureStates({ + teamIds: [team.id], + featureIds: [testFeature, secondFeature], + }); + + expect(result[testFeature]?.[team.id]).toBe("enabled"); + expect(result[secondFeature]?.[team.id]).toBe("disabled"); + + await prisma.teamFeatures.deleteMany({ + where: { teamId: team.id, featureId: { in: [testFeature, secondFeature] } }, + }); + await prisma.feature.deleteMany({ + where: { slug: secondFeature }, + }); + }); + }); + + describe("getUserAutoOptIn", () => { + it("should return false when user has autoOptInFeatures=false (default)", async () => { + const { user, featuresRepository } = await setup(); + + const result = await featuresRepository.getUserAutoOptIn(user.id); + expect(result).toBe(false); + }); + + it("should return true when user has autoOptInFeatures=true", async () => { + const { user, featuresRepository } = await setup(); + + await prisma.user.update({ + where: { id: user.id }, + data: { autoOptInFeatures: true }, + }); + + const result = await featuresRepository.getUserAutoOptIn(user.id); + expect(result).toBe(true); + }); + + it("should return false for non-existent user", async () => { + const { featuresRepository } = await setup(); + + const result = await featuresRepository.getUserAutoOptIn(999999); + expect(result).toBe(false); + }); + }); + + describe("getTeamsAutoOptIn", () => { + it("should return empty object for empty teamIds array", async () => { + const { featuresRepository } = await setup(); + + const result = await featuresRepository.getTeamsAutoOptIn([]); + expect(result).toEqual({}); + }); + + it("should return false for team with autoOptInFeatures=false (default)", async () => { + const { team, featuresRepository } = await setup(); + + const result = await featuresRepository.getTeamsAutoOptIn([team.id]); + expect(result[team.id]).toBe(false); + }); + + it("should return true for team with autoOptInFeatures=true", async () => { + const { team, featuresRepository } = await setup(); + + await prisma.team.update({ + where: { id: team.id }, + data: { autoOptInFeatures: true }, + }); + + const result = await featuresRepository.getTeamsAutoOptIn([team.id]); + expect(result[team.id]).toBe(true); + }); + + it("should return values for multiple teams in a single query", async () => { + const { team, featuresRepository } = await setup(); + + const secondTeam = await prisma.team.create({ + data: { + name: `Second Team ${uniqueId()}`, + slug: `second-team-auto-opt-in-${uniqueId()}`, + autoOptInFeatures: true, + }, + }); + + // team has autoOptInFeatures=false (default) + // secondTeam has autoOptInFeatures=true + + const result = await featuresRepository.getTeamsAutoOptIn([team.id, secondTeam.id]); + expect(result[team.id]).toBe(false); + expect(result[secondTeam.id]).toBe(true); + + // Clean up + await prisma.team.delete({ + where: { id: secondTeam.id }, + }); + }); + + it("should not include non-existent teams in result", async () => { + const { team, featuresRepository } = await setup(); + + const result = await featuresRepository.getTeamsAutoOptIn([team.id, 999999]); + expect(result[team.id]).toBe(false); + expect(result[999999]).toBeUndefined(); + }); + }); + + describe("setUserAutoOptIn", () => { + it("should set autoOptInFeatures to true", async () => { + const { user, featuresRepository } = await setup(); + + // Ensure user starts with false + const before = await featuresRepository.getUserAutoOptIn(user.id); + expect(before).toBe(false); + + await featuresRepository.setUserAutoOptIn(user.id, true); + + const after = await featuresRepository.getUserAutoOptIn(user.id); + expect(after).toBe(true); + }); + + it("should set autoOptInFeatures to false", async () => { + const { user, featuresRepository } = await setup(); + + // First set to true + await featuresRepository.setUserAutoOptIn(user.id, true); + const before = await featuresRepository.getUserAutoOptIn(user.id); + expect(before).toBe(true); + + // Now set to false + await featuresRepository.setUserAutoOptIn(user.id, false); + + const after = await featuresRepository.getUserAutoOptIn(user.id); + expect(after).toBe(false); + }); + }); + + describe("setTeamAutoOptIn", () => { + it("should set autoOptInFeatures to true", async () => { + const { team, featuresRepository } = await setup(); + + // Ensure team starts with false + const before = await featuresRepository.getTeamsAutoOptIn([team.id]); + expect(before[team.id]).toBe(false); + + await featuresRepository.setTeamAutoOptIn(team.id, true); + + const after = await featuresRepository.getTeamsAutoOptIn([team.id]); + expect(after[team.id]).toBe(true); + }); + + it("should set autoOptInFeatures to false", async () => { + const { team, featuresRepository } = await setup(); + + // First set to true + await featuresRepository.setTeamAutoOptIn(team.id, true); + const before = await featuresRepository.getTeamsAutoOptIn([team.id]); + expect(before[team.id]).toBe(true); + + // Now set to false + await featuresRepository.setTeamAutoOptIn(team.id, false); + + const after = await featuresRepository.getTeamsAutoOptIn([team.id]); + expect(after[team.id]).toBe(false); + }); + }); }); diff --git a/packages/features/flags/features.repository.interface.ts b/packages/features/flags/features.repository.interface.ts index 9a18dc0a7b..7f0db8eb5a 100644 --- a/packages/features/flags/features.repository.interface.ts +++ b/packages/features/flags/features.repository.interface.ts @@ -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; + checkIfFeatureIsEnabledGlobally(slug: FeatureId): Promise; checkIfUserHasFeature(userId: number, slug: string): Promise; getUserFeaturesStatus(userId: number, slugs: string[]): Promise>; checkIfUserHasFeatureNonHierarchical(userId: number, slug: string): Promise; - checkIfTeamHasFeature(teamId: number, slug: keyof AppFlags): Promise; - getTeamsWithFeatureEnabled(slug: keyof AppFlags): Promise; - setTeamFeatureState( - teamId: number, - featureId: keyof AppFlags, - state: FeatureState, - assignedBy: string + checkIfTeamHasFeature(teamId: number, slug: FeatureId): Promise; + getTeamsWithFeatureEnabled(slug: FeatureId): Promise; + setUserFeatureState( + input: + | { userId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: string } + | { userId: number; featureId: FeatureId; state: "inherit" } ): Promise; + setTeamFeatureState( + input: + | { teamId: number; featureId: FeatureId; state: "enabled" | "disabled"; assignedBy: string } + | { teamId: number; featureId: FeatureId; state: "inherit" } + ): Promise; + /** + * Get user's feature states for multiple features. + * @returns Record + */ + getUserFeatureStates(input: { + userId: number; + featureIds: FeatureId[]; + }): Promise>; + /** + * Get multiple features' states across multiple teams. + * Optimized for querying many teams for many features. + * @returns Record> + */ + getTeamsFeatureStates(input: { + teamIds: number[]; + featureIds: FeatureId[]; + }): Promise>>; + /** + * Get user's autoOptInFeatures flag. + * @returns Promise - True if user has auto opt-in enabled + */ + getUserAutoOptIn(userId: number): Promise; + /** + * Get autoOptInFeatures for multiple teams (batch). + * @returns Promise> - Map of teamId to autoOptInFeatures value + */ + getTeamsAutoOptIn(teamIds: number[]): Promise>; + /** + * Set user's autoOptInFeatures flag. + */ + setUserAutoOptIn(userId: number, enabled: boolean): Promise; + /** + * Set team's autoOptInFeatures flag. + */ + setTeamAutoOptIn(teamId: number, enabled: boolean): Promise; } diff --git a/packages/features/flags/features.repository.mock.ts b/packages/features/flags/features.repository.mock.ts index eb08314ab2..4db074a4ab 100644 --- a/packages/features/flags/features.repository.mock.ts +++ b/packages/features/flags/features.repository.mock.ts @@ -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> { - return Object.fromEntries(slugs.map((slug) => [slug, slug === "mock-feature"])); + async getUserFeaturesStatus( + _userId: number, + slugs: string[] + ): Promise> { + 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 { + async getTeamsWithFeatureEnabled(_slug: FeatureId): Promise { 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 { // Mock implementation - do nothing } + + async setTeamFeatureState( + _input: + | { + teamId: number; + featureId: FeatureId; + state: "enabled" | "disabled"; + assignedBy: string; + } + | { teamId: number; featureId: FeatureId; state: "inherit" } + ): Promise { + // Mock implementation - do nothing + } + + async getUserFeatureStates(_input: { + userId: number; + featureIds: FeatureId[]; + }): Promise> { + // Mock implementation - return inherit for all features + const result: Record = {}; + for (const featureId of _input.featureIds) { + result[featureId] = "inherit"; + } + return result; + } + + async getTeamsFeatureStates(_input: { + teamIds: number[]; + featureIds: FeatureId[]; + }): Promise>> { + // Mock implementation - return empty (all teams inherit) + return {}; + } + + async getUserAutoOptIn(_userId: number): Promise { + // Mock implementation - default to false + return false; + } + + async getTeamsAutoOptIn( + _teamIds: number[] + ): Promise> { + // Mock implementation - return empty (all teams default to false) + return {}; + } + + async setUserAutoOptIn(_userId: number, _enabled: boolean): Promise { + // Mock implementation - do nothing + } + + async setTeamAutoOptIn(_teamId: number, _enabled: boolean): Promise { + // Mock implementation - do nothing + } } diff --git a/packages/features/flags/features.repository.ts b/packages/features/flags/features.repository.ts index 76f3651cf8..46fb3700df 100644 --- a/packages/features/flags/features.repository.ts +++ b/packages/features/flags/features.repository.ts @@ -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 { 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 + * @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 { + 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 * @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 { + 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 - 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 { + async checkIfTeamHasFeature(teamId: number, featureId: FeatureId): Promise { 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 { + async getTeamsWithFeatureEnabled(slug: FeatureId): Promise { 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 + */ + async getUserFeatureStates(input: { + userId: number; + featureIds: FeatureId[]; + }): Promise>> { + const { userId, featureIds } = input; + + try { + // Initialize result with all features set to 'inherit' + const result: Partial> = {}; + 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> + */ + async getTeamsFeatureStates(input: { + teamIds: number[]; + featureIds: FeatureId[]; + }): Promise>>> { + const { teamIds, featureIds } = input; + + if (teamIds.length === 0 || featureIds.length === 0) { + return {} as Partial>>; + } + + try { + // Initialize result with all features present to avoid undefined feature keys + const result: Partial>> = {}; + 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 - True if user has auto opt-in enabled, false otherwise + */ + async getUserAutoOptIn(userId: number): Promise { + 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> - Map of teamId to autoOptInFeatures value + */ + async getTeamsAutoOptIn(teamIds: number[]): Promise> { + 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 = {}; + 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 + */ + async setUserAutoOptIn(userId: number, enabled: boolean): Promise { + 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 + */ + async setTeamAutoOptIn(teamId: number, enabled: boolean): Promise { + try { + await this.prismaClient.team.update({ + where: { id: teamId }, + data: { autoOptInFeatures: enabled }, + }); + } catch (err) { + captureException(err); + throw err; + } + } } diff --git a/packages/features/flags/operations/check-if-user-has-feature.controller.test.ts b/packages/features/flags/operations/check-if-user-has-feature.controller.test.ts index 2e631394e1..225667dd83 100644 --- a/packages/features/flags/operations/check-if-user-has-feature.controller.test.ts +++ b/packages/features/flags/operations/check-if-user-has-feature.controller.test.ts @@ -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); diff --git a/packages/features/flags/operations/check-if-user-has-feature.use-case.test.ts b/packages/features/flags/operations/check-if-user-has-feature.use-case.test.ts index 29d55ef045..78feb312f5 100644 --- a/packages/features/flags/operations/check-if-user-has-feature.use-case.test.ts +++ b/packages/features/flags/operations/check-if-user-has-feature.use-case.test.ts @@ -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); diff --git a/packages/features/membership/repositories/MembershipRepository.ts b/packages/features/membership/repositories/MembershipRepository.ts index 0fea70bd5f..b5c8b82272 100644 --- a/packages/features/membership/repositories/MembershipRepository.ts +++ b/packages/features/membership/repositories/MembershipRepository.ts @@ -531,6 +531,7 @@ export class MembershipRepository { select: { id: true, parentId: true, + isOrganization: true, }, }, }, diff --git a/packages/features/pbac/infrastructure/repositories/__tests__/PermissionRepository.integration-test.ts b/packages/features/pbac/infrastructure/repositories/__tests__/PermissionRepository.integration-test.ts index 20dde1cd96..fd1bb8e273 100644 --- a/packages/features/pbac/infrastructure/repositories/__tests__/PermissionRepository.integration-test.ts +++ b/packages/features/pbac/infrastructure/repositories/__tests__/PermissionRepository.integration-test.ts @@ -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] } } }); }); diff --git a/packages/lib/server/repository/selectedCalendar.test.ts b/packages/lib/server/repository/selectedCalendar.test.ts index 03e81f3241..e356e4dd07 100644 --- a/packages/lib/server/repository/selectedCalendar.test.ts +++ b/packages/lib/server/repository/selectedCalendar.test.ts @@ -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({ diff --git a/packages/prisma/migrations/20251217155117_add_auto_opt_in_features/migration.sql b/packages/prisma/migrations/20251217155117_add_auto_opt_in_features/migration.sql new file mode 100644 index 0000000000..12fe20966e --- /dev/null +++ b/packages/prisma/migrations/20251217155117_add_auto_opt_in_features/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 5a27213308..a355345d15 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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]) } diff --git a/packages/trpc/server/routers/viewer/_router.tsx b/packages/trpc/server/routers/viewer/_router.tsx index aab6ef7f86..14fda5a5cf 100644 --- a/packages/trpc/server/routers/viewer/_router.tsx +++ b/packages/trpc/server/routers/viewer/_router.tsx @@ -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, diff --git a/packages/trpc/server/routers/viewer/admin/assignFeatureToTeam.handler.ts b/packages/trpc/server/routers/viewer/admin/assignFeatureToTeam.handler.ts index e9755566e6..c2f660fbae 100644 --- a/packages/trpc/server/routers/viewer/admin/assignFeatureToTeam.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/assignFeatureToTeam.handler.ts @@ -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 }; diff --git a/packages/trpc/server/routers/viewer/admin/unassignFeatureFromTeam.handler.ts b/packages/trpc/server/routers/viewer/admin/unassignFeatureFromTeam.handler.ts index 5b4eeb802d..d04c6c9691 100644 --- a/packages/trpc/server/routers/viewer/admin/unassignFeatureFromTeam.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/unassignFeatureFromTeam.handler.ts @@ -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 }; diff --git a/packages/trpc/server/routers/viewer/featureOptIn/_router.ts b/packages/trpc/server/routers/viewer/featureOptIn/_router.ts new file mode 100644 index 0000000000..6ce702528d --- /dev/null +++ b/packages/trpc/server/routers/viewer/featureOptIn/_router.ts @@ -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 }; + }), +}); diff --git a/packages/trpc/server/routers/viewer/pbac/_router.tsx b/packages/trpc/server/routers/viewer/pbac/_router.tsx index f6731aa8c0..1f9da6e4f7 100644 --- a/packages/trpc/server/routers/viewer/pbac/_router.tsx +++ b/packages/trpc/server/routers/viewer/pbac/_router.tsx @@ -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" }; }), diff --git a/scripts/prepare-local-for-delegation-credentials-testing.js b/scripts/prepare-local-for-delegation-credentials-testing.js index 7c569e2d01..5977f1304c 100644 --- a/scripts/prepare-local-for-delegation-credentials-testing.js +++ b/scripts/prepare-local-for-delegation-credentials-testing.js @@ -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 { diff --git a/scripts/seed-pbac-organization.ts b/scripts/seed-pbac-organization.ts index 427e484d0e..35237b7f54 100644 --- a/scripts/seed-pbac-organization.ts +++ b/scripts/seed-pbac-organization.ts @@ -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})`);