feat: add enabled column to UserFeatures and TeamFeatures for tri-state semantics (#25765)
* feat: add enabled column to UserFeatures and TeamFeatures for tri-state semantics - Add enabled Boolean column to UserFeatures model with default true - Add enabled Boolean column to TeamFeatures model with default true - Update FeaturesRepository to use tri-state semantics: - enabled=true: feature is explicitly enabled - enabled=false: feature is explicitly disabled (blocks inheritance) - No row: inherit from team/org level - Update SQL queries to check enabled=true for feature access - Add enableFeatureForTeam method to interface and implementation Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * update comments * add integration tests * add more test * select enabled only * no @default(true) * fix types and tests * add missing enabled * add missing enabled * rename enableFeatureForTeam to updateFeatureForTeam and support FeatureState * refactor: rename updateFeatureForTeam to setTeamFeatureState Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix integration test * fix tests * add more tests * add missing enabled --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
+1
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"],
|
||||
{
|
||||
|
||||
@@ -24,6 +24,7 @@ export const enablePBACForTeam = async (teamId: number) => {
|
||||
teamId: teamId,
|
||||
assignedBy: "e2e",
|
||||
assignedAt: new Date(),
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -37,3 +37,5 @@ export type AppFlags = {
|
||||
};
|
||||
|
||||
export type TeamFeatures = Record<keyof AppFlags, boolean>;
|
||||
|
||||
export type FeatureState = "enabled" | "disabled" | "inherit";
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<boolean>;
|
||||
checkIfUserHasFeature(userId: number, slug: string): Promise<boolean>;
|
||||
checkIfUserHasFeatureNonHierarchical(userId: number, slug: string): Promise<boolean>;
|
||||
checkIfTeamHasFeature(teamId: number, slug: keyof AppFlags): Promise<boolean>;
|
||||
getTeamsWithFeatureEnabled(slug: keyof AppFlags): Promise<number[]>;
|
||||
setTeamFeatureState(
|
||||
teamId: number,
|
||||
featureId: keyof AppFlags,
|
||||
state: FeatureState,
|
||||
assignedBy: string
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -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<number[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async setTeamFeatureState(
|
||||
_teamId: number,
|
||||
_featureId: keyof AppFlags,
|
||||
_state: FeatureState,
|
||||
_assignedBy: string
|
||||
): Promise<void> {
|
||||
// Mock implementation - do nothing
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { captureException } from "@sentry/nextjs";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import type { AppFlags, 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<boolean> - 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<boolean> - 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<boolean> - True if the user belongs to a team with the feature, false otherwise
|
||||
* @returns Promise<boolean> - 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<boolean> - True if the user belongs to a team with the feature (direct only)
|
||||
* @returns Promise<boolean> - 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<void>
|
||||
* @throws Error if the feature enabling fails
|
||||
*/
|
||||
async enableFeatureForTeam(teamId: number, featureId: keyof AppFlags, assignedBy: string): Promise<void> {
|
||||
async setTeamFeatureState(
|
||||
teamId: number,
|
||||
featureId: keyof AppFlags,
|
||||
state: FeatureState,
|
||||
assignedBy: string
|
||||
): Promise<void> {
|
||||
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<boolean> - True if the team or any ancestor has the feature, false otherwise
|
||||
* @returns Promise<boolean> - True if the team or any ancestor has the feature enabled, false otherwise
|
||||
* @throws Error if the database query fails
|
||||
*/
|
||||
async checkIfTeamHasFeature(teamId: number, featureId: keyof AppFlags): Promise<boolean> {
|
||||
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" },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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})
|
||||
|
||||
+39
-26
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+9
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -53,6 +53,7 @@ export const getTeamsForFeatureHandler = async ({ ctx, input }: GetTeamsOptions)
|
||||
prisma.teamFeatures.findMany({
|
||||
where: {
|
||||
featureId,
|
||||
enabled: true,
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
|
||||
@@ -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" };
|
||||
}),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -57,6 +57,7 @@ export async function createPBACOrganization() {
|
||||
teamId: organization.id,
|
||||
assignedBy: "system (Seed script)",
|
||||
assignedAt: new Date(),
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user