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 88ef73256a..229761957f 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,7 @@ describe("Organizations Roles Endpoints", () => { }); await featuresRepositoryFixture.create({ slug: "pbac", enabled: true }); - await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledOrganization.id, "pbac"); + await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledOrganization.id, "pbac", "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 4890e1a203..bc04ac3d3a 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,7 @@ describe("Organizations Roles Permissions Endpoints", () => { }); await featuresRepositoryFixture.create({ slug: "pbac", enabled: true }); - await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledOrganization.id, "pbac"); + await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledOrganization.id, "pbac", "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 ab69ce2dd1..9aa1f9f42a 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,7 @@ describe("Organizations Roles Endpoints", () => { }); await featuresRepositoryFixture.create({ slug: "pbac", enabled: true }); - await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledTeam.id, "pbac"); + await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledTeam.id, "pbac", "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 a7a949b94d..637bcf10a1 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,7 @@ describe("Organizations Teams Roles Permissions Endpoints", () => { }); await featuresRepositoryFixture.create({ slug: "pbac", enabled: true }); - await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledTeam.id, "pbac"); + await featuresRepositoryFixture.setTeamFeatureState(pbacEnabledTeam.id, "pbac", "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 0216b1baac..b5b964d44d 100644 --- a/apps/api/v2/test/fixtures/repository/features.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/features.repository.fixture.ts @@ -28,21 +28,38 @@ export class FeaturesRepositoryFixture { }); } - async enableFeatureForTeam(teamId: number, featureId: string, assignedBy = "test") { - await this.prismaWriteClient.teamFeatures.upsert({ - where: { - teamId_featureId: { + async setTeamFeatureState( + teamId: number, + featureId: string, + state: "enabled" | "disabled" | "inherit", + assignedBy = "test" + ) { + 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", + }, + }); + } else if (state === "inherit") { + await this.prismaWriteClient.teamFeatures.deleteMany({ + where: { teamId, featureId, }, - }, - create: { - teamId, - featureId, - assignedBy, - }, - update: {}, - }); + }); + } } async disableFeatureForTeam(teamId: number, featureSlug: string) { diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx index e3e71e95a0..729d5309d0 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx @@ -20,7 +20,7 @@ import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient"; const getTeamFeatures = unstable_cache( async (teamId: number) => { const featuresRepository = new FeaturesRepository(prisma); - return await featuresRepository.getTeamFeatures(teamId); + return await featuresRepository.getEnabledTeamFeatures(teamId); }, ["team-features"], { diff --git a/apps/web/playwright/lib/test-helpers/pbac.ts b/apps/web/playwright/lib/test-helpers/pbac.ts index 806ae55a09..0535fcf64d 100644 --- a/apps/web/playwright/lib/test-helpers/pbac.ts +++ b/apps/web/playwright/lib/test-helpers/pbac.ts @@ -24,6 +24,7 @@ export const enablePBACForTeam = async (teamId: number) => { teamId: teamId, assignedBy: "e2e", assignedAt: new Date(), + enabled: true, }, }); }; diff --git a/packages/features/ee/teams/repositories/TeamRepository.ts b/packages/features/ee/teams/repositories/TeamRepository.ts index 5605ed2472..4a8fd96231 100644 --- a/packages/features/ee/teams/repositories/TeamRepository.ts +++ b/packages/features/ee/teams/repositories/TeamRepository.ts @@ -556,7 +556,7 @@ export class TeamRepository { FROM "Membership" m INNER JOIN "User" u ON m."userId" = u.id LEFT JOIN "Role" r ON m."customRoleId" = r.id - LEFT JOIN "TeamFeatures" f ON m."teamId" = f."teamId" AND f."featureId" = 'pbac' + LEFT JOIN "TeamFeatures" f ON m."teamId" = f."teamId" AND f."featureId" = 'pbac' AND f.enabled = true WHERE m."teamId" = ${teamId} AND m."accepted" = true AND ( diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 9a15789484..43d0e2033a 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -37,3 +37,5 @@ export type AppFlags = { }; export type TeamFeatures = Record; + +export type FeatureState = "enabled" | "disabled" | "inherit"; diff --git a/packages/features/flags/features.repository.integration-test.ts b/packages/features/flags/features.repository.integration-test.ts index fb41d5b3f8..9c340744c9 100644 --- a/packages/features/flags/features.repository.integration-test.ts +++ b/packages/features/flags/features.repository.integration-test.ts @@ -1,5 +1,7 @@ import { describe, expect, beforeAll, afterAll, beforeEach, it } from "vitest"; -import prisma from "@calcom/prisma"; + +import { prisma } from "@calcom/prisma"; + import type { AppFlags } from "./config"; import { FeaturesRepository } from "./features.repository"; @@ -7,7 +9,7 @@ const featuresRepository = new FeaturesRepository(prisma); // Access private clearCache method through type assertion const clearCache = () => { - (featuresRepository as any).clearCache(); + (featuresRepository as unknown as { clearCache: () => void }).clearCache(); }; describe("FeaturesRepository Integration Tests", () => { @@ -134,6 +136,31 @@ describe("FeaturesRepository Integration Tests", () => { expect(result).toBe(false); }); + it("should return false when no UserFeatures row and no TeamFeatures row exist (tri-state inheritance)", async () => { + // Create the feature globally + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + // User is a member of a team + await prisma.membership.create({ + data: { + teamId: testTeam.id, + userId: testUser.id, + role: "MEMBER", + accepted: true, + }, + }); + + // No UserFeatures row, no TeamFeatures row - should return false + const result = await featuresRepository.checkIfUserHasFeature(testUser.id, testFeature); + expect(result).toBe(false); + }); + it("should return true when user has feature directly assigned", async () => { await prisma.feature.create({ data: { @@ -148,6 +175,7 @@ describe("FeaturesRepository Integration Tests", () => { userId: testUser.id, featureId: testFeature, assignedBy: "test", + enabled: true, }, }); @@ -155,6 +183,71 @@ describe("FeaturesRepository Integration Tests", () => { expect(result).toBe(true); }); + it("should return false when user has feature with enabled=false (tri-state)", async () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + await prisma.userFeatures.create({ + data: { + userId: testUser.id, + featureId: testFeature, + enabled: false, + assignedBy: "test", + }, + }); + + const result = await featuresRepository.checkIfUserHasFeature(testUser.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 () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + // Team has the feature enabled + await prisma.teamFeatures.create({ + data: { + teamId: testTeam.id, + featureId: testFeature, + enabled: true, + assignedBy: "test", + }, + }); + + // User is a member of the team + await prisma.membership.create({ + data: { + teamId: testTeam.id, + userId: testUser.id, + role: "MEMBER", + accepted: true, + }, + }); + + // But user has explicitly disabled the feature + await prisma.userFeatures.create({ + data: { + userId: testUser.id, + featureId: testFeature, + enabled: false, + assignedBy: "test", + }, + }); + + const result = await featuresRepository.checkIfUserHasFeature(testUser.id, testFeature); + expect(result).toBe(false); + }); + it("should return true when user belongs to team with feature", async () => { await prisma.feature.create({ data: { @@ -169,6 +262,7 @@ describe("FeaturesRepository Integration Tests", () => { teamId: testTeam.id, featureId: testFeature, assignedBy: "test", + enabled: true, }, }); @@ -185,6 +279,38 @@ describe("FeaturesRepository Integration Tests", () => { expect(result).toBe(true); }); + it("should return false when user belongs to team with feature disabled (tri-state)", async () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + // Team has the feature explicitly disabled + await prisma.teamFeatures.create({ + data: { + teamId: testTeam.id, + featureId: testFeature, + enabled: false, + assignedBy: "test", + }, + }); + + await prisma.membership.create({ + data: { + teamId: testTeam.id, + userId: testUser.id, + role: "MEMBER", + accepted: true, + }, + }); + + const result = await featuresRepository.checkIfUserHasFeature(testUser.id, testFeature); + expect(result).toBe(false); + }); + it("should return true when user belongs to team where parent team has feature", async () => { // Create an organization const org = await prisma.team.create({ @@ -219,6 +345,7 @@ describe("FeaturesRepository Integration Tests", () => { teamId: org.id, featureId: testFeature, assignedBy: "test", + enabled: true, }, }); @@ -275,6 +402,7 @@ describe("FeaturesRepository Integration Tests", () => { teamId: testTeam.id, featureId: testFeature, assignedBy: "test", + enabled: true, }, }); @@ -282,6 +410,28 @@ describe("FeaturesRepository Integration Tests", () => { expect(result).toBe(true); }); + it("should return false when team has feature with enabled=false (tri-state)", async () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + await prisma.teamFeatures.create({ + data: { + teamId: testTeam.id, + featureId: testFeature, + enabled: false, + assignedBy: "test", + }, + }); + + const result = await featuresRepository.checkIfTeamHasFeature(testTeam.id, testFeature); + expect(result).toBe(false); + }); + it("should return true when parent team has feature", async () => { const parentTeam = await prisma.team.create({ data: { @@ -308,6 +458,7 @@ describe("FeaturesRepository Integration Tests", () => { teamId: parentTeam.id, featureId: testFeature, assignedBy: "test", + enabled: true, }, }); @@ -322,5 +473,447 @@ describe("FeaturesRepository Integration Tests", () => { where: { id: parentTeam.id }, }); }); + + it("should return false when parent has feature with enabled=false (tri-state)", async () => { + const parentTeam = await prisma.team.create({ + data: { + name: `Parent Team ${Date.now()}`, + slug: `parent-team-${Date.now()}`, + }, + }); + + await prisma.team.update({ + where: { id: testTeam.id }, + data: { parentId: parentTeam.id }, + }); + + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + // Parent has the feature explicitly disabled + await prisma.teamFeatures.create({ + data: { + teamId: parentTeam.id, + featureId: testFeature, + enabled: false, + assignedBy: "test", + }, + }); + + const result = await featuresRepository.checkIfTeamHasFeature(testTeam.id, testFeature); + expect(result).toBe(false); + + // Clean up parent team + await prisma.teamFeatures.deleteMany({ + where: { teamId: parentTeam.id }, + }); + await prisma.team.delete({ + where: { id: parentTeam.id }, + }); + }); + }); + + describe("checkIfUserHasFeatureNonHierarchical", () => { + it("should return false when user does not have feature", async () => { + const result = await featuresRepository.checkIfUserHasFeatureNonHierarchical(testUser.id, testFeature); + expect(result).toBe(false); + }); + + it("should return true when user has feature directly assigned", async () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + await prisma.userFeatures.create({ + data: { + userId: testUser.id, + featureId: testFeature, + enabled: true, + assignedBy: "test", + }, + }); + + const result = await featuresRepository.checkIfUserHasFeatureNonHierarchical(testUser.id, testFeature); + expect(result).toBe(true); + }); + + it("should return false when user has feature with enabled=false (tri-state)", async () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + await prisma.userFeatures.create({ + data: { + userId: testUser.id, + featureId: testFeature, + enabled: false, + assignedBy: "test", + }, + }); + + const result = await featuresRepository.checkIfUserHasFeatureNonHierarchical(testUser.id, testFeature); + expect(result).toBe(false); + }); + + it("should return true when user belongs to direct team with feature (non-hierarchical)", async () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + await prisma.teamFeatures.create({ + data: { + teamId: testTeam.id, + featureId: testFeature, + enabled: true, + assignedBy: "test", + }, + }); + + await prisma.membership.create({ + data: { + teamId: testTeam.id, + userId: testUser.id, + role: "MEMBER", + accepted: true, + }, + }); + + const result = await featuresRepository.checkIfUserHasFeatureNonHierarchical(testUser.id, testFeature); + expect(result).toBe(true); + }); + + it("should return false when user belongs to direct team with feature disabled (tri-state)", async () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + await prisma.teamFeatures.create({ + data: { + teamId: testTeam.id, + featureId: testFeature, + enabled: false, + assignedBy: "test", + }, + }); + + await prisma.membership.create({ + data: { + teamId: testTeam.id, + userId: testUser.id, + role: "MEMBER", + accepted: true, + }, + }); + + const result = await featuresRepository.checkIfUserHasFeatureNonHierarchical(testUser.id, testFeature); + expect(result).toBe(false); + }); + }); + + describe("setTeamFeatureState", () => { + it("should create a new TeamFeatures row with enabled=true", async () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + await featuresRepository.setTeamFeatureState(testTeam.id, testFeature, "enabled", "test-assigner"); + + const teamFeature = await prisma.teamFeatures.findUnique({ + where: { + teamId_featureId: { + teamId: testTeam.id, + featureId: testFeature, + }, + }, + }); + + expect(teamFeature).not.toBeNull(); + expect(teamFeature?.enabled).toBe(true); + expect(teamFeature?.assignedBy).toBe("test-assigner"); + }); + + it("should update existing TeamFeatures row to enabled=true", async () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + // Create with enabled=false first + await prisma.teamFeatures.create({ + data: { + teamId: testTeam.id, + featureId: testFeature, + enabled: false, + assignedBy: "original-assigner", + }, + }); + + // Now enable the feature + await featuresRepository.setTeamFeatureState(testTeam.id, testFeature, "enabled", "new-assigner"); + + const teamFeature = await prisma.teamFeatures.findUnique({ + where: { + teamId_featureId: { + teamId: testTeam.id, + featureId: testFeature, + }, + }, + }); + + expect(teamFeature).not.toBeNull(); + expect(teamFeature?.enabled).toBe(true); + expect(teamFeature?.assignedBy).toBe("new-assigner"); + }); + + it("should create a new TeamFeatures row with enabled=false", async () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + await featuresRepository.setTeamFeatureState(testTeam.id, testFeature, "disabled", "test-assigner"); + + const teamFeature = await prisma.teamFeatures.findUnique({ + where: { + teamId_featureId: { + teamId: testTeam.id, + featureId: testFeature, + }, + }, + }); + + expect(teamFeature).not.toBeNull(); + expect(teamFeature?.enabled).toBe(false); + expect(teamFeature?.assignedBy).toBe("test-assigner"); + }); + + it("should update existing TeamFeatures row to enabled=false", async () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + // Create with enabled=true first + await prisma.teamFeatures.create({ + data: { + teamId: testTeam.id, + featureId: testFeature, + enabled: true, + assignedBy: "original-assigner", + }, + }); + + // Now disable the feature + await featuresRepository.setTeamFeatureState(testTeam.id, testFeature, "disabled", "new-assigner"); + + const teamFeature = await prisma.teamFeatures.findUnique({ + where: { + teamId_featureId: { + teamId: testTeam.id, + featureId: testFeature, + }, + }, + }); + + expect(teamFeature).not.toBeNull(); + expect(teamFeature?.enabled).toBe(false); + expect(teamFeature?.assignedBy).toBe("new-assigner"); + }); + + it("should delete TeamFeatures row when state is inherit", async () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + // Create a TeamFeatures row first + await prisma.teamFeatures.create({ + data: { + teamId: testTeam.id, + featureId: testFeature, + enabled: true, + assignedBy: "original-assigner", + }, + }); + + // Verify the row exists + const beforeDelete = await prisma.teamFeatures.findUnique({ + where: { + teamId_featureId: { + teamId: testTeam.id, + featureId: testFeature, + }, + }, + }); + expect(beforeDelete).not.toBeNull(); + + // Now set to inherit - should delete the row + await featuresRepository.setTeamFeatureState(testTeam.id, testFeature, "inherit", "test-assigner"); + + const teamFeature = await prisma.teamFeatures.findUnique({ + where: { + teamId_featureId: { + teamId: testTeam.id, + featureId: testFeature, + }, + }, + }); + + expect(teamFeature).toBeNull(); + }); + + it("should handle inherit state when no TeamFeatures row exists", async () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + // Call inherit without any existing row - should not throw + await featuresRepository.setTeamFeatureState(testTeam.id, testFeature, "inherit", "test-assigner"); + + const teamFeature = await prisma.teamFeatures.findUnique({ + where: { + teamId_featureId: { + teamId: testTeam.id, + featureId: testFeature, + }, + }, + }); + + expect(teamFeature).toBeNull(); + }); + }); + + describe("getTeamsWithFeatureEnabled", () => { + it("should return empty array when no teams have the feature", async () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + clearCache(); + + const result = await featuresRepository.getTeamsWithFeatureEnabled(testFeature); + expect(result).toEqual([]); + }); + + it("should return teams with enabled=true only", async () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: true, + type: "OPERATIONAL", + }, + }); + + // Create a second team + const secondTeam = await prisma.team.create({ + data: { + name: `Second Team ${Date.now()}`, + slug: `second-team-${Date.now()}`, + }, + }); + + // testTeam has feature enabled + await prisma.teamFeatures.create({ + data: { + teamId: testTeam.id, + featureId: testFeature, + enabled: true, + assignedBy: "test", + }, + }); + + // secondTeam has feature disabled + await prisma.teamFeatures.create({ + data: { + teamId: secondTeam.id, + featureId: testFeature, + enabled: false, + assignedBy: "test", + }, + }); + + clearCache(); + + const result = await featuresRepository.getTeamsWithFeatureEnabled(testFeature); + expect(result).toContain(testTeam.id); + expect(result).not.toContain(secondTeam.id); + + // Clean up second team + await prisma.teamFeatures.deleteMany({ + where: { teamId: secondTeam.id }, + }); + await prisma.team.delete({ + where: { id: secondTeam.id }, + }); + }); + + it("should return empty array when feature is not globally enabled", async () => { + await prisma.feature.create({ + data: { + slug: testFeature, + enabled: false, // Globally disabled + type: "OPERATIONAL", + }, + }); + + await prisma.teamFeatures.create({ + data: { + teamId: testTeam.id, + featureId: testFeature, + enabled: true, + assignedBy: "test", + }, + }); + + clearCache(); + + const result = await featuresRepository.getTeamsWithFeatureEnabled(testFeature); + expect(result).toEqual([]); + }); }); }); diff --git a/packages/features/flags/features.repository.interface.ts b/packages/features/flags/features.repository.interface.ts index 782714d4d9..fcbca26d25 100644 --- a/packages/features/flags/features.repository.interface.ts +++ b/packages/features/flags/features.repository.interface.ts @@ -1,8 +1,19 @@ -import type { AppFlags } from "./config"; +import type { AppFlags, 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; checkIfUserHasFeature(userId: number, slug: 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 + ): Promise; } diff --git a/packages/features/flags/features.repository.mock.ts b/packages/features/flags/features.repository.mock.ts index 9f860d24f3..b081076f14 100644 --- a/packages/features/flags/features.repository.mock.ts +++ b/packages/features/flags/features.repository.mock.ts @@ -1,13 +1,33 @@ +import type { AppFlags, FeatureState } from "./config"; import type { IFeaturesRepository } from "./features.repository.interface"; export class MockFeaturesRepository implements IFeaturesRepository { async checkIfUserHasFeature(userId: number, slug: string) { return slug === "mock-feature"; } - async checkIfTeamHasFeature(team: number, slug: string) { + + async checkIfUserHasFeatureNonHierarchical(userId: number, slug: string) { return slug === "mock-feature"; } - async checkIfFeatureIsEnabledGlobally() { + + async checkIfTeamHasFeature(teamId: number, slug: keyof AppFlags) { + return slug === "mock-feature"; + } + + async checkIfFeatureIsEnabledGlobally(_slug: keyof AppFlags) { return true; } + + async getTeamsWithFeatureEnabled(_slug: keyof AppFlags): Promise { + return []; + } + + async setTeamFeatureState( + _teamId: number, + _featureId: keyof AppFlags, + _state: FeatureState, + _assignedBy: string + ): Promise { + // Mock implementation - do nothing + } } diff --git a/packages/features/flags/features.repository.ts b/packages/features/flags/features.repository.ts index 48e3bd0104..7cdced3122 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, TeamFeatures } from "./config"; +import type { AppFlags, FeatureState, TeamFeatures } from "./config"; import type { IFeaturesRepository } from "./features.repository.interface"; interface CacheOptions { @@ -65,16 +65,16 @@ export class FeaturesRepository implements IFeaturesRepository { * @param teamId - The ID of the team to get features for * @returns Promise<{ [slug: string]: boolean } | null> */ - public async getTeamFeatures(teamId: number) { + public async getEnabledTeamFeatures(teamId: number) { const result = await this.prismaClient.teamFeatures.findMany({ where: { teamId, + enabled: true, }, - include: { + select: { feature: { select: { slug: true, - enabled: true, }, }, }, @@ -110,8 +110,12 @@ export class FeaturesRepository implements IFeaturesRepository { } /** - * Checks if a specific user has access to a feature. - * Checks both direct user feature assignments and team-based feature access. + * Checks if a specific user has access to a feature based on user and team assignments. + * Uses tri-state semantics: + * - Row with enabled=true → feature is enabled + * - Row with enabled=false → feature is explicitly disabled (blocks inheritance) + * - No row → inherit from team/org level + * * @param userId - The ID of the user to check * @param slug - The feature identifier to check * @returns Promise - True if the user has access to the feature, false otherwise @@ -124,18 +128,23 @@ export class FeaturesRepository implements IFeaturesRepository { * FIXME refactor when upgrading prismock * https://github.com/morintd/prismock/issues/592 */ - const userHasFeature = await this.prismaClient.userFeatures.findFirst({ + const userFeature = await this.prismaClient.userFeatures.findFirst({ where: { userId, featureId: slug, }, + select: { enabled: true }, }); - if (userHasFeature) return true; - // If the user doesn't have the feature, check if they belong to a team with the feature. + + // If user has an explicit setting, use it + if (userFeature) { + return userFeature.enabled; + } + + // If no user-level setting, check if they belong to a team with the feature. // This also covers organizations, which are teams. const userBelongsToTeamWithFeature = await this.checkIfUserBelongsToTeamWithFeature(userId, slug); - if (userBelongsToTeamWithFeature) return true; - return false; + return userBelongsToTeamWithFeature; } catch (err) { captureException(err); throw err; @@ -145,6 +154,11 @@ export class FeaturesRepository implements IFeaturesRepository { /** * Checks if a specific user has access to a feature, ignoring hierarchical (parent) teams. * Only checks direct user assignments and direct team memberships — does not traverse parents. + * Uses tri-state semantics: + * - Row with enabled=true → feature is enabled + * - Row with enabled=false → feature is explicitly disabled + * - No row → inherit from direct team memberships + * * @param userId - The ID of the user to check * @param slug - The feature identifier to check * @returns Promise - True if the user has direct or same-level team access to the feature @@ -153,16 +167,18 @@ export class FeaturesRepository implements IFeaturesRepository { async checkIfUserHasFeatureNonHierarchical(userId: number, slug: string) { try { // Prismock limitation: findUnique may fail, use findFirst instead - const userHasFeature = await this.prismaClient.userFeatures.findFirst({ - select: { - userId: true, - }, + const userFeature = await this.prismaClient.userFeatures.findFirst({ where: { userId, featureId: slug, }, + select: { enabled: true }, }); - if (userHasFeature) return true; + + // If user has an explicit setting, use it + if (userFeature) { + return userFeature.enabled; + } const userBelongsToTeamWithFeature = await this.checkIfUserBelongsToTeamWithFeatureNonHierarchical( userId, @@ -178,9 +194,10 @@ export class FeaturesRepository implements IFeaturesRepository { /** * Private helper method to check if a user belongs to any team that has access to a feature. + * Uses tri-state semantics: only treats as enabled if TeamFeatures row exists AND enabled=true. * @param userId - The ID of the user to check * @param slug - The feature identifier to check - * @returns Promise - True if the user belongs to a team with the feature, false otherwise + * @returns Promise - True if the user belongs to a team with the feature enabled, false otherwise * @throws Error if the team feature check fails * @private */ @@ -192,7 +209,7 @@ export class FeaturesRepository implements IFeaturesRepository { SELECT DISTINCT t.id, t."parentId", CASE WHEN EXISTS ( SELECT 1 FROM "TeamFeatures" tf - WHERE tf."teamId" = t.id AND tf."featureId" = ${slug} + WHERE tf."teamId" = t.id AND tf."featureId" = ${slug} AND tf."enabled" = true ) THEN true ELSE false END as has_feature FROM "Team" t INNER JOIN "Membership" m ON m."teamId" = t.id @@ -204,7 +221,7 @@ export class FeaturesRepository implements IFeaturesRepository { SELECT DISTINCT p.id, p."parentId", CASE WHEN EXISTS ( SELECT 1 FROM "TeamFeatures" tf - WHERE tf."teamId" = p.id AND tf."featureId" = ${slug} + WHERE tf."teamId" = p.id AND tf."featureId" = ${slug} AND tf."enabled" = true ) THEN true ELSE false END as has_feature FROM "Team" p INNER JOIN TeamHierarchy c ON p.id = c."parentId" @@ -227,9 +244,10 @@ export class FeaturesRepository implements IFeaturesRepository { /** * Checks if a user belongs to any direct team that has access to a feature. * This version ignores parent/child team relationships — no recursion or hierarchy traversal. + * Uses tri-state semantics: only treats as enabled if TeamFeatures row exists AND enabled=true. * @param userId - The ID of the user to check * @param slug - The feature identifier to check - * @returns Promise - True if the user belongs to a team with the feature (direct only) + * @returns Promise - True if the user belongs to a team with the feature enabled (direct only) * @throws Error if the query fails * @private */ @@ -246,6 +264,7 @@ export class FeaturesRepository implements IFeaturesRepository { FROM "TeamFeatures" tf WHERE tf."teamId" = t.id AND tf."featureId" = ${slug} + AND tf."enabled" = true ) LIMIT 1; `; @@ -259,29 +278,49 @@ export class FeaturesRepository implements IFeaturesRepository { } /** - * Enables a feature for a specific team. + * 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 * @returns Promise * @throws Error if the feature enabling fails */ - async enableFeatureForTeam(teamId: number, featureId: keyof AppFlags, assignedBy: string): Promise { + async setTeamFeatureState( + teamId: number, + featureId: keyof AppFlags, + state: FeatureState, + assignedBy: string + ): Promise { try { - await this.prismaClient.teamFeatures.upsert({ - where: { - teamId_featureId: { + if (state === "enabled" || state === "disabled") { + await this.prismaClient.teamFeatures.upsert({ + where: { + teamId_featureId: { + teamId, + featureId, + }, + }, + create: { + teamId, + featureId, + enabled: state === "enabled", + assignedBy, + }, + update: { + enabled: state === "enabled", + assignedBy, + }, + }); + } else if (state === "inherit") { + await this.prismaClient.teamFeatures.deleteMany({ + where: { teamId, featureId, }, - }, - create: { - teamId, - featureId, - assignedBy, - }, - update: {}, - }); + }); + } // Clear cache when features are modified this.clearCache(); } catch (err) { @@ -293,23 +332,26 @@ export class FeaturesRepository implements IFeaturesRepository { /** * Checks if a team or any of its ancestors has access to a specific feature. * Uses a recursive CTE raw SQL query for performance. + * Uses tri-state semantics: only treats as enabled if TeamFeatures row exists AND enabled=true. + * * @param teamId - The ID of the team to start the check from * @param featureId - The feature identifier to check - * @returns Promise - True if the team or any ancestor has the feature, false otherwise + * @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 { try { - // Early return if team has feature directly assigned - const teamHasFeature = await this.prismaClient.teamFeatures.findUnique({ + // Early return if team has feature directly assigned with enabled=true + const teamFeature = await this.prismaClient.teamFeatures.findUnique({ where: { teamId_featureId: { teamId, featureId, }, }, + select: { enabled: true }, }); - if (teamHasFeature) return true; + if (teamFeature) return teamFeature.enabled; const query = Prisma.sql` WITH RECURSIVE TeamHierarchy AS ( @@ -317,7 +359,7 @@ export class FeaturesRepository implements IFeaturesRepository { SELECT id, "parentId", CASE WHEN EXISTS ( SELECT 1 FROM "TeamFeatures" tf - WHERE tf."teamId" = id AND tf."featureId" = ${featureId} + WHERE tf."teamId" = id AND tf."featureId" = ${featureId} AND tf."enabled" = true ) THEN true ELSE false END as has_feature FROM "Team" WHERE id = ${teamId} @@ -328,7 +370,7 @@ export class FeaturesRepository implements IFeaturesRepository { SELECT p.id, p."parentId", CASE WHEN EXISTS ( SELECT 1 FROM "TeamFeatures" tf - WHERE tf."teamId" = p.id AND tf."featureId" = ${featureId} + WHERE tf."teamId" = p.id AND tf."featureId" = ${featureId} AND tf."enabled" = true ) THEN true ELSE false END as has_feature FROM "Team" p INNER JOIN TeamHierarchy c ON p.id = c."parentId" @@ -358,8 +400,12 @@ export class FeaturesRepository implements IFeaturesRepository { const isGloballyEnabled = await this.checkIfFeatureIsEnabledGlobally(slug); if (!isGloballyEnabled) return []; + // Only return teams where enabled=true (tri-state semantics) const rows = await this.prismaClient.teamFeatures.findMany({ - where: { featureId: slug }, + where: { + featureId: slug, + enabled: true, + }, select: { teamId: true }, orderBy: { teamId: "asc" }, }); 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 cafbb74980..2e631394e1 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 @@ -16,6 +16,7 @@ it("checks if user has access to feature", async () => { featureId: "mock-feature", assignedBy: "1", updatedAt: new Date(), + enabled: true, }, }); await expect(checkIfUserHasFeatureController(userId, "nonexistent-feature")).resolves.toBe(false); 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 814255abf7..29d55ef045 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 @@ -14,6 +14,7 @@ it("returns if user has access to feature", async () => { featureId: "mock-feature", assignedBy: "1", updatedAt: new Date(), + enabled: true, }, }); await expect(checkIfUserHasFeatureUseCase(userId, "nonexistent-feature")).resolves.toBe(false); diff --git a/packages/features/pbac/infrastructure/repositories/PermissionRepository.ts b/packages/features/pbac/infrastructure/repositories/PermissionRepository.ts index 2e1a6abc32..d6d5a6d1dc 100644 --- a/packages/features/pbac/infrastructure/repositories/PermissionRepository.ts +++ b/packages/features/pbac/infrastructure/repositories/PermissionRepository.ts @@ -301,7 +301,7 @@ export class PermissionRepository implements IPermissionRepository { SELECT DISTINCT m."teamId" FROM "Membership" m INNER JOIN "Team" t ON m."teamId" = t.id - LEFT JOIN "TeamFeatures" f ON f."teamId" = t.id AND f."featureId" = ${this.PBAC_FEATURE_FLAG} + LEFT JOIN "TeamFeatures" f ON f."teamId" = t.id AND f."featureId" = ${this.PBAC_FEATURE_FLAG} AND f.enabled = true WHERE m."userId" = ${userId} AND m."accepted" = true AND m."role"::text = ANY(${fallbackRoles}) @@ -311,7 +311,7 @@ export class PermissionRepository implements IPermissionRepository { FROM "Membership" m INNER JOIN "Team" org ON m."teamId" = org.id INNER JOIN "Team" child ON child."parentId" = org.id - LEFT JOIN "TeamFeatures" f ON f."teamId" = org.id AND f."featureId" = ${this.PBAC_FEATURE_FLAG} + LEFT JOIN "TeamFeatures" f ON f."teamId" = org.id AND f."featureId" = ${this.PBAC_FEATURE_FLAG} AND f.enabled = true WHERE m."userId" = ${userId} AND m."accepted" = true AND m."role"::text = ANY(${fallbackRoles}) 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 3672f1a6e1..20dde1cd96 100644 --- a/packages/features/pbac/infrastructure/repositories/__tests__/PermissionRepository.integration-test.ts +++ b/packages/features/pbac/infrastructure/repositories/__tests__/PermissionRepository.integration-test.ts @@ -17,11 +17,13 @@ describe("PermissionRepository - Integration Tests", () => { }); beforeEach(async () => { + const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + // Create test user const testUser = await prisma.user.create({ data: { - email: `test-${Date.now()}@example.com`, - username: `testuser-${Date.now()}`, + email: `test-${uniqueId}@example.com`, + username: `testuser-${uniqueId}`, }, }); testUserId = testUser.id; @@ -29,8 +31,8 @@ describe("PermissionRepository - Integration Tests", () => { // Create test team const testTeam = await prisma.team.create({ data: { - name: `Test Team ${Date.now()}`, - slug: `test-team-${Date.now()}`, + name: `Test Team ${uniqueId}`, + slug: `test-team-${uniqueId}`, }, }); testTeamId = testTeam.id; @@ -343,6 +345,7 @@ describe("PermissionRepository - Integration Tests", () => { teamId: testTeamId, featureId: "pbac", assignedBy: "test", + enabled: true, }, }); @@ -388,6 +391,7 @@ describe("PermissionRepository - Integration Tests", () => { teamId: testTeamId, featureId: "pbac", assignedBy: "test", + enabled: true, }, }); @@ -469,11 +473,12 @@ describe("PermissionRepository - Integration Tests", () => { }); it("should return child team IDs when user has PBAC permissions via org", async () => { + const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; // Create 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, }, }); @@ -484,13 +489,14 @@ describe("PermissionRepository - Integration Tests", () => { teamId: org.id, featureId: "pbac", assignedBy: "test", + enabled: true, }, }); // Create org role const orgRole = await prisma.role.create({ data: { - name: `Org Role ${Date.now()}`, + name: `Org Role ${uniqueId}`, teamId: org.id, }, }); @@ -523,8 +529,8 @@ describe("PermissionRepository - Integration Tests", () => { // Create child team const childTeam = await prisma.team.create({ data: { - name: `Child Team ${Date.now()}`, - slug: `child-team-${Date.now()}`, + name: `Child Team ${uniqueId}`, + slug: `child-team-${uniqueId}`, parentId: org.id, }, }); @@ -546,11 +552,12 @@ describe("PermissionRepository - Integration Tests", () => { }); it("should return child team IDs when user has fallback roles via org", async () => { + const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; // Create 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, }, }); @@ -571,8 +578,8 @@ describe("PermissionRepository - Integration Tests", () => { // Create child team const childTeam = await prisma.team.create({ data: { - name: `Child Team ${Date.now()}`, - slug: `child-team-${Date.now()}`, + name: `Child Team ${uniqueId}`, + slug: `child-team-${uniqueId}`, parentId: org.id, }, }); @@ -597,6 +604,7 @@ describe("PermissionRepository - Integration Tests", () => { teamId: testTeamId, featureId: "pbac", assignedBy: "test", + enabled: true, }, }); @@ -642,6 +650,7 @@ describe("PermissionRepository - Integration Tests", () => { teamId: testTeamId, featureId: "pbac", assignedBy: "test", + enabled: true, }, }); @@ -681,6 +690,7 @@ describe("PermissionRepository - Integration Tests", () => { teamId: testTeamId, featureId: "pbac", assignedBy: "test", + enabled: true, }, }); @@ -713,17 +723,18 @@ describe("PermissionRepository - Integration Tests", () => { }); it("should return multiple teams when user has permissions on multiple teams", async () => { + const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; // Create first team with its own role const team1 = await prisma.team.create({ data: { - name: `Test Team 1 ${Date.now()}`, - slug: `test-team-1-${Date.now()}`, + name: `Test Team 1 ${uniqueId}`, + slug: `test-team-1-${uniqueId}`, }, }); const role1 = await prisma.role.create({ data: { - name: `Test Role 1 ${Date.now()}`, + name: `Test Role 1 ${uniqueId}`, teamId: team1.id, }, }); @@ -731,14 +742,14 @@ describe("PermissionRepository - Integration Tests", () => { // Create second team with its own role const team2 = await prisma.team.create({ data: { - name: `Test Team 2 ${Date.now()}`, - slug: `test-team-2-${Date.now()}`, + name: `Test Team 2 ${uniqueId}`, + slug: `test-team-2-${uniqueId}`, }, }); const role2 = await prisma.role.create({ data: { - name: `Test Role 2 ${Date.now()}`, + name: `Test Role 2 ${uniqueId}`, teamId: team2.id, }, }); @@ -746,8 +757,8 @@ describe("PermissionRepository - Integration Tests", () => { // Enable PBAC for both teams await prisma.teamFeatures.createMany({ data: [ - { teamId: team1.id, featureId: "pbac", assignedBy: "test" }, - { teamId: team2.id, featureId: "pbac", assignedBy: "test" }, + { teamId: team1.id, featureId: "pbac", assignedBy: "test", enabled: true }, + { teamId: team2.id, featureId: "pbac", assignedBy: "test", enabled: true }, ], }); @@ -819,17 +830,18 @@ describe("PermissionRepository - Integration Tests", () => { }); it("should combine PBAC and fallback teams in results", async () => { + const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; // Create first team for PBAC const team1 = await prisma.team.create({ data: { - name: `Test Team 1 ${Date.now()}`, - slug: `test-team-1-${Date.now()}`, + name: `Test Team 1 ${uniqueId}`, + slug: `test-team-1-${uniqueId}`, }, }); const role1 = await prisma.role.create({ data: { - name: `Test Role 1 ${Date.now()}`, + name: `Test Role 1 ${uniqueId}`, teamId: team1.id, }, }); @@ -837,8 +849,8 @@ describe("PermissionRepository - Integration Tests", () => { // Create second team for fallback const team2 = await prisma.team.create({ data: { - name: `Test Team 2 ${Date.now()}`, - slug: `test-team-2-${Date.now()}`, + name: `Test Team 2 ${uniqueId}`, + slug: `test-team-2-${uniqueId}`, }, }); @@ -848,6 +860,7 @@ describe("PermissionRepository - Integration Tests", () => { teamId: team1.id, featureId: "pbac", assignedBy: "test", + enabled: true, }, }); diff --git a/packages/lib/server/repository/selectedCalendar.test.ts b/packages/lib/server/repository/selectedCalendar.test.ts index 0729918055..03e81f3241 100644 --- a/packages/lib/server/repository/selectedCalendar.test.ts +++ b/packages/lib/server/repository/selectedCalendar.test.ts @@ -3,6 +3,7 @@ import prismock from "../../../../tests/libs/__mocks__/prisma"; import { describe, expect, it, beforeEach } from "vitest"; import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; import { SelectedCalendarRepository } from "./selectedCalendar"; @@ -11,6 +12,55 @@ describe("SelectedCalendarRepository", () => { prismock.selectedCalendar.deleteMany(); }); + describe("getNextBatchToWatch", () => { + it("excludes calendars when calendar-cache feature is disabled on a team", async () => { + const user = await prisma.user.create({ + data: { + email: "calendar-cache-disabled@example.com", + username: "calendar-cache-disabled", + }, + }); + + const team = await prisma.team.create({ + data: { + name: "Calendar Cache Disabled Team", + slug: "calendar-cache-disabled-team", + }, + }); + + await prisma.membership.create({ + data: { + userId: user.id, + teamId: team.id, + role: MembershipRole.ADMIN, + accepted: true, + }, + }); + + await prisma.teamFeatures.create({ + data: { + teamId: team.id, + featureId: "calendar-cache", + enabled: false, + assignedBy: "test", + }, + }); + + await prisma.selectedCalendar.create({ + data: { + userId: user.id, + integration: "google_calendar", + externalId: "disabled@example.com", + credentialId: 1, + }, + }); + + const nextBatch = await SelectedCalendarRepository.getNextBatchToWatch(); + + expect(nextBatch).toEqual([]); + }); + }); + describe("create", () => { it("should create a selected calendar", async () => { const data = { diff --git a/packages/lib/server/repository/selectedCalendar.ts b/packages/lib/server/repository/selectedCalendar.ts index 0538e842ce..a715528d1e 100644 --- a/packages/lib/server/repository/selectedCalendar.ts +++ b/packages/lib/server/repository/selectedCalendar.ts @@ -158,6 +158,7 @@ export class SelectedCalendarRepository { features: { some: { featureId: "calendar-cache", + enabled: true, }, }, }, @@ -238,6 +239,7 @@ export class SelectedCalendarRepository { features: { none: { featureId: "calendar-cache", + enabled: true, }, }, }, diff --git a/packages/prisma/migrations/20251210143104_add_enabled_to_user_and_team_features/migration.sql b/packages/prisma/migrations/20251210143104_add_enabled_to_user_and_team_features/migration.sql new file mode 100644 index 0000000000..bde838d3d0 --- /dev/null +++ b/packages/prisma/migrations/20251210143104_add_enabled_to_user_and_team_features/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable: Add enabled column with DEFAULT true so existing rows get enabled=true +ALTER TABLE "public"."TeamFeatures" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true; + +-- AlterTable: Add enabled column with DEFAULT true so existing rows get enabled=true +ALTER TABLE "public"."UserFeatures" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true; + +-- Remove the DEFAULT constraint so new rows must explicitly set enabled +ALTER TABLE "public"."TeamFeatures" ALTER COLUMN "enabled" DROP DEFAULT; +ALTER TABLE "public"."UserFeatures" ALTER COLUMN "enabled" DROP DEFAULT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 6139410d06..17153784fb 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1702,6 +1702,7 @@ model UserFeatures { userId Int feature Feature @relation(fields: [featureId], references: [slug], onDelete: Cascade) featureId String + enabled Boolean assignedAt DateTime @default(now()) assignedBy String updatedAt DateTime @updatedAt @@ -1715,6 +1716,7 @@ model TeamFeatures { teamId Int feature Feature @relation(fields: [featureId], references: [slug], onDelete: Cascade) featureId String + enabled Boolean assignedAt DateTime @default(now()) assignedBy String updatedAt DateTime @updatedAt diff --git a/packages/prisma/sql/getSelectedCalendarsToWatch.sql b/packages/prisma/sql/getSelectedCalendarsToWatch.sql index 59074d9500..657e03c82d 100644 --- a/packages/prisma/sql/getSelectedCalendarsToWatch.sql +++ b/packages/prisma/sql/getSelectedCalendarsToWatch.sql @@ -10,6 +10,7 @@ FROM WHERE -- Only get calendars for teams where cache is enabled tf."featureId" = 'calendar-cache' + AND tf.enabled = true -- We currently only support google watchers AND sc."integration" = 'google_calendar' AND ( diff --git a/packages/trpc/server/routers/viewer/admin/assignFeatureToTeam.handler.ts b/packages/trpc/server/routers/viewer/admin/assignFeatureToTeam.handler.ts index 910a3d7390..e9755566e6 100644 --- a/packages/trpc/server/routers/viewer/admin/assignFeatureToTeam.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/assignFeatureToTeam.handler.ts @@ -26,8 +26,11 @@ export const assignFeatureToTeamHandler = async ({ ctx, input }: AssignFeatureOp teamId, featureId, assignedBy: `user:${user.id}`, + enabled: true, + }, + update: { + enabled: true, }, - update: {}, }); return { success: true }; diff --git a/packages/trpc/server/routers/viewer/admin/getTeamsForFeature.handler.ts b/packages/trpc/server/routers/viewer/admin/getTeamsForFeature.handler.ts index 3021764591..bf71a01483 100644 --- a/packages/trpc/server/routers/viewer/admin/getTeamsForFeature.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/getTeamsForFeature.handler.ts @@ -53,6 +53,7 @@ export const getTeamsForFeatureHandler = async ({ ctx, input }: GetTeamsOptions) prisma.teamFeatures.findMany({ where: { featureId, + enabled: true, }, select: { teamId: true, diff --git a/packages/trpc/server/routers/viewer/pbac/_router.tsx b/packages/trpc/server/routers/viewer/pbac/_router.tsx index 59ba4fafde..f6731aa8c0 100644 --- a/packages/trpc/server/routers/viewer/pbac/_router.tsx +++ b/packages/trpc/server/routers/viewer/pbac/_router.tsx @@ -249,7 +249,7 @@ export const permissionsRouter = router({ } // Enable PBAC feature for the organization - await featureRepo.enableFeatureForTeam(orgId, "pbac", "opt-in by user: " + ctx.user.id); + await featureRepo.setTeamFeatureState(orgId, "pbac", "enabled", "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 8f9ee504f7..7c569e2d01 100644 --- a/scripts/prepare-local-for-delegation-credentials-testing.js +++ b/scripts/prepare-local-for-delegation-credentials-testing.js @@ -55,6 +55,7 @@ async function main() { teamId: org.id, featureId: "delegation-credential", }, + enabled: true, }, }); if (!delegationFeature) { @@ -64,6 +65,7 @@ async function main() { featureId: "delegation-credential", assignedAt: new Date(), assignedBy: "prepare-local-script", + enabled: true, }, }); console.log("Created TeamFeatures: delegation-credential"); @@ -78,6 +80,7 @@ async function main() { teamId: org.id, featureId: "calendar-cache", }, + enabled: true, }, }); if (!calendarCacheFeature) { @@ -87,6 +90,7 @@ async function main() { featureId: "calendar-cache", assignedAt: new Date(), assignedBy: "prepare-local-script", + enabled: true, }, }); console.log("Created TeamFeatures: calendar-cache"); diff --git a/scripts/seed-pbac-organization.ts b/scripts/seed-pbac-organization.ts index 01a527b9ba..427e484d0e 100644 --- a/scripts/seed-pbac-organization.ts +++ b/scripts/seed-pbac-organization.ts @@ -57,6 +57,7 @@ export async function createPBACOrganization() { teamId: organization.id, assignedBy: "system (Seed script)", assignedAt: new Date(), + enabled: true, }, });